Multi-binary (bundle) ABI analysis¶
Most ABI tools answer one question: "did this .so file's ABI change?"
Real-world releases — oneDAL, libtorch, Intel MKL, the bundled CUDA
runtime — ship several .so files that depend on each other.
Per-library compare misses entire classes of breakage that live in the
relationships between siblings. The bundle layer fixes
that.
This page covers:
- What "bundle analysis" actually checks
- The bundle-analysis flags on
compare(directory/package inputs) and what they do - The manifest file format
- How to read the JSON / markdown output
- When you'd want to turn it off
What the bundle layer catches¶
| Scenario | Per-library compare says | Bundle layer says |
|---|---|---|
libcore.so removes core_mul; libalgo.so still imports it |
libcore: BREAKING; libalgo: NO_CHANGE | + bundle_intra_dep_removed on libalgo |
libcore.so changes core_add(int,int) → core_add(long,long) (extern C, same mangled name); libalgo is byte-identical |
libcore: BREAKING; libalgo: NO_CHANGE | + bundle_intra_dep_signature_changed on libalgo |
Type detail::Context defined in libcore changes layout; libalgo's exported symbols embed it as a template parameter |
libcore: BREAKING; libalgo: NO_CHANGE | + bundle_intra_type_changed on libalgo |
shared_util moves from libcore to libutil; bundle still exports it once |
libcore: BREAKING (func_removed); libutil: COMPATIBLE (func_added) |
+ bundle_provider_changed (COMPATIBLE_WITH_RISK) |
| Removed library was depended on by a surviving sibling | libcore removed (worst-of) | + bundle_library_removed with consumer attribution |
Symbol's gnu.version_d tag drifts (GLIBCXX_3.4.20 → GLIBCXX_3.4.30) |
unchanged | + bundle_intra_dep_resolved_to_different_version |
Manifest promises train_double_sparse; new bundle doesn't export it |
per-library func_removed (can't tell promised from incidental) |
+ bundle_manifest_instantiation_removed |
Per-library findings are unchanged — the bundle layer only adds
cross-library findings; it never hides them. The aggregate verdict
becomes the worst of bundle_verdict and the per-library worst.
Bundle findings answer a different question than public-surface findings¶
A bundle_* kind answers "does the shipped bundle still work end-to-end"
— not "did the public API change". bundle_intra_dep_removed and its
siblings are classified as BREAKING/COMPATIBLE_WITH_RISK/etc. through the
same registry/verdict machinery every other ChangeKind uses — they aren't a
separate category — but the scoping layer that sits in front of that
classification treats some of them differently, and which bundle detector
you're looking at determines whether that's true.
First, a terminology note this page relies on throughout: scoping and
policy are two separate mechanisms, not two names for the same thing.
Public-header scoping (on by default) and an explicit surface allowlist
control whether a Change is removed from DiffResult.changes at all —
unlike a --policy document's overrides: block, which never removes a
Change from changes and only reclassifies which Verdict a given
ChangeKind maps to. So "a policy profile scoped to the public surface"
isn't a real, separate filtering mechanism — a private func_removed still
shows up in the report under any --policy profile; only
public-header scoping (or suppression, a third, separate mechanism —
see below) decides whether it's there at all.
compare_bundle()/analyze_bundle() honor a custom PolicyFile for
bundle findings on every entry point, including the CLI's directory/
package release fan-out (compare-release) — G38 Phase 16 closed the one
remaining gap. compare_bundle() has a second, optional policy_file:
PolicyFile | None parameter alongside its original bare policy: str
name — when supplied, BundleDiffResult.bundle_verdict scores bundle_*
findings through policy_file.compute_verdict(changes) directly (the same
call the per-library path already makes), instead of always falling back
to the coarse three-way policy_kind_sets() switch
(strict_abi/sdk_vendor/plugin_abi) compute_verdict()'s own
docstring describes. bundle_analysis.analyze_bundle() (the shared
orchestrator both a live comparison and a stored-facts comparison route
through) accepts and forwards the same parameter. The stored
BundleFacts Python-API driver — bundle_facts.compare_bundle_from_facts(),
bundle_side_input.compare_release_against_bundle_facts() — resolves and threads a real
policy_file through to it, and so does the CLI's directory/package
compare-release fan-out: pack_application.resolve_bundle_policy_file()
resolves it (called from cli_compare_release.py), and
cli_compare_release_helpers._collect_bundle_result() sets it on the
BundleDiffResult it already got back from _run_bundle_analysis()
before reading bundle_verdict — policy_file is a plain mutable field
and bundle_verdict a lazily-computed property, so this reaches the
identical outcome without changing _run_bundle_analysis()'s own
signature — so a --policy custom.yaml document's overrides: entry for e.g.
bundle_intra_dep_removed now reaches the release fan-out's aggregate
bundle verdict on every entry point. See
G38's plan, Phase 16
for the fix's history. Direct, per-finding suppression of a bundle_*
kind is still not supported on any entry point — see the suppression
section below, unchanged by this.
Graph-native detectors ignore public-surface scoping entirely.
bundle_intra_dep_removed, bundle_library_removed/bundle_library_added,
bundle_intra_dep_resolved_to_different_version, bundle_soname_skew, and
manifest enforcement (bundle_manifest_instantiation_*) work directly from
the bundle's own ELF resolution graph and declared contracts (manifest,
SONAME cohorts) — never from a per-library DiffResult's already-scoped
changes list. core_mul in the table above never needs to be part of
libcore.so's public API for bundle_intra_dep_removed to fire: libalgo.so
still imports it via DT_NEEDED, so removing it breaks libalgo.so's runtime
load regardless of whether any external consumer ever called core_mul
directly, and public-header scoping never touches this detector's input at
all.
Diff-derived detectors no longer inherit public-header scoping through
starvation — G38 Phase 14 closed that gap. bundle_intra_dep_signature_
changed, bundle_intra_type_changed, and bundle_provider_changed are
computed by scanning each library's own per-library DiffResult for the
specific kinds they promote (func_params_changed/func_return_changed/
var_type_changed for the signature-change detector, type_size_changed/
type_field_removed/etc. for the type-change detector, func_removed+
func_added pairs for the provider-migration detector) — but the source
they scan is diff.changes plus diff.out_of_surface_changes, not
diff.changes alone. Public-header scoping (on by default) never
drops a non-public-surface finding — post_processing.
FilterNonPublicSurface moves it to out_of_surface_changes instead (a "recorded, never silently dropped" ledger) — so an internal,
headerless C export with no public header naming either side still reaches
these three detectors, exactly as it should: the standalone per-library
report correctly excludes it from that library's own public API, but the
bundle's internal linkage contract between two siblings is a different
question, and these three detectors exist specifically to answer it.
Each detector keeps its own, already-shipped reachability rule unchanged —
this phase did not unify them to one gate: the signature-change detector
still requires a real resolved import edge
(resolution.consumers_of/_consumer_resolves_via_provider), the
type-change detector still requires only a name-embedding symbol-table
match (never an import edge — see that detector's own docstring for why an
import-edge requirement would be a strictly narrower, wrong gate here), and
the provider-migration detector still requires no reachability at all (a
provider move breaks an external consumer of the old DSO exactly as much as
a bundle sibling).
Contrast either case with an ordinary per-library finding that never gets
promoted to a bundle finding at all (func_removed on something no sibling
imports): that one is filtered from the per-library report by
public-header scoping, same as any other per-library finding — but is
unaffected by which --policy profile is selected, since policy never
removes a finding, only reclassifies its verdict.
No bundle_* kind can be suppressed directly — and, unlike scoping,
suppression still reaches a diff-derived finding indirectly through
starvation, on the CLI fan-out specifically. compare_bundle() is never
given a suppression ruleset itself, so no suppression
rule can target a bundle_* kind by name — that part holds for every
bundle_* kind, with no exception, on every entry point. On the
directory/package compare fan-out, --suppress is applied to each
library's DiffResult.changes before it reaches compare_bundle() (the
same per-library compare pipeline that applies public-header scoping) —
and post_processing.py's own step ordering runs FilterNonPublicSurface
before ApplySuppression, so a change already demoted to
out_of_surface_changes never reaches ApplySuppression at all and can
never be suppressed, while a change that stayed in-surface can still be
suppressed out of diff.changes there. So, for the three diff-derived
detectors, a suppression rule targeting the underlying per-library kind
(func_params_changed, type_size_changed, func_removed/func_added)
still starves the bundle detector for an in-surface finding exactly like
before — this is a side effect of suppressing the per-library finding, not
a way to suppress the bundle finding on its own terms — but has no
effect on an out-of-surface finding these detectors now also see, since
that finding was never checked against --suppress in the first place.
Closing that narrower residual (re-running suppression against the
out-of-surface ledger specifically) is tracked as a known gap, not
attempted as part of this fix — see the G38 plan doc's own Phase 14 entry.
For the remaining, graph-native kinds
(bundle_intra_dep_removed, bundle_library_removed/_added, version
drift, SONAME skew, manifest enforcement), there is no per-library Change
to suppress upstream of them at all, so the only lever is, for a symbol
that genuinely comes from outside the release, .abicheck.yml's
bundle.system_providers: (see below). --no-bundle-analysis (which used
to turn off bundle analysis for the whole run) is gone — bundle-level
analysis always runs now (Phase 7d, one-comparison-product.md §4.1): a run-wide analysis opt-out was exactly the "escape hatch that
disables real analysis" D5 rules out.
The sibling-consumption gate covers most, but not all, kinds — and even
those are gated only inside compare_bundle() itself. Within
compare_bundle(), five kinds require a sibling to actually consume the
affected symbol/library before firing: bundle_intra_dep_removed (an import
with no provider at all), bundle_library_removed (a removed library, gated
on whether a surviving sibling actually imported one of its exports — a
standalone removal with no internal consumer there is by design left to the
directory/package CLI's separate .abicheck.yml's gate.fail_on_removed_library flow),
bundle_intra_dep_signature_changed and bundle_intra_type_changed (each
gated on new.resolution.consumers_of(...)/a sibling's own symbols, as
described above), and bundle_intra_dep_resolved_to_different_version
(gated on new.resolution.consumers_of(symbol) returning at least one other
library — an exported version bump nobody in the bundle actually imports
produces no finding). Two kinds have no such gate even inside
compare_bundle(): bundle_library_added fires for any new library
unconditionally, and bundle_provider_changed fires whenever a symbol
migrates from one sibling to another, whether or not any third sibling
consumes it. Manifest/SONAME-cohort findings
(bundle_manifest_instantiation_removed, bundle_soname_skew) are a third
category entirely — driven by their own declared contract, not by internal
consumption at all — a manifest promising a since-removed symbol produces
bundle_manifest_instantiation_removed even if no sibling in the bundle ever
imported it.
An unconsumed, unmanifested internal export removal (within a single
compare_bundle() call) is the
one case that falls through to the ordinary per-library func_removed,
governed by the usual public-surface/suppression rules, unaffected by the
bundle layer.
Running it¶
The bundle layer is enabled by default:
If the bundle is broken, you'll see a new section in the markdown summary and new top-level keys in the JSON output:
| **Verdict** | ❌ `BREAKING` |
| **Bundle** | ❌ `BREAKING` (2 cross-library findings) |
## 🔗 Bundle (Cross-Library) Findings
- **bundle_intra_dep_removed** — `core_mul` (consumer: `libalgo.so`)
- libalgo.so imports core_mul, but no library in the new bundle exports it.
Runtime load of libalgo.so will fail with undefined symbol.
- **bundle_intra_dep_signature_changed** — `core_add` (consumer: `libalgo.so`) (provider: `libcore.so`)
- libalgo.so calls core_add (mangled name unchanged) but libcore.so
altered its DWARF signature. Calling convention is now mismatched.
The bundle-analysis flags¶
--instantiation-manifest PATH (Experimental)¶
You probably don't need this flag. For 95% of releases the headers passed to
-H include/already define the public ABI contract, and the bundle layer derives the rest from ELF resolution.--instantiation-manifestcovers a narrow set of cases where the contract lives outside the headers. The manifest schema is still being shaped — expect changes between minor versions.
What headers + bundle resolution already give you (no manifest needed):
- Every public function, type, class declared in headers, with full signature / layout diff.
- Cross-DSO symbol resolution — sibling drops a symbol another sibling
still imports,
extern "C"signature drift, provider migration. - Type drift propagated through template-instantiated symbols.
When --instantiation-manifest actually adds something:
- Template instantiation lists.
extern template foo<int>;in a header is just a declaration; the contract is which specific instantiations get emitted as symbols in the .so. That list lives in build files /*_ops.cppfiles, not in headers. - dlopen/dlsym plugin contracts. Symbols loaded at runtime by name with no header declaration.
- Internal-but-stable APIs. Symbols intentionally exported for trusted consumers (e.g. test harnesses, sibling tooling) but kept out of the public headers.
- Symbol-version promises. Specific
foo@GLIBCXX_3.4.30guarantees that headers can't express.
You do not need to hand-list every symbol. Listing tens of thousands of mangled names is impractical, fragile (mangling shifts with compiler ABI / inline-namespace bumps), and unmaintainable. The manifest schema provides three entry shapes for this reason:
Entry shape 1 — pattern: (most useful)¶
Glob (fnmatch) matched against the demangled form of every
exported symbol. The entry passes iff at least one symbol in the new
bundle matches the glob.
version: 1
provides:
- pattern: "oneapi::dal::train_ops<*>*" # any instantiation of train_ops
library: libonedal_core.so.1
optional_provider: false
- pattern: "oneapi::dal::detail::*" # internal helpers — optional
library: libonedal_core.so.1
optional_provider: true
- pattern: "onedal_ext_*" # extern-C plugin entry points
library: libonedal_core.so.1
optional_provider: false
Patterns work for both C++ (matched against the demangled form) and
extern "C" symbols (matched against the literal name, since they
don't demangle).
Entry shape 2 — template: + instantiations: (the right shape for template libs)¶
The contract for template-heavy libraries (oneDAL, libtorch, MKL) is the explicit instantiation matrix the build system enumerates. The manifest expresses that directly:
version: 1
provides:
- template: oneapi::dal::train_ops
instantiations:
- {Float: float, Method: "method::dense", Task: "task::train"}
- {Float: float, Method: "method::sparse", Task: "task::train"}
- {Float: double, Method: "method::dense", Task: "task::train"}
- {Float: double, Method: "method::sparse", Task: "task::train"}
library: libonedal_core.so.1
optional_provider: false
abicheck expands each instantiation into the demangled form
Template<v1, v2, ...> and checks that some exported symbol's
demangled name contains it as a substring. Parameter values appear in
the angle-bracket list in the order the manifest declares them — so
the parameter order in each instantiations entry must match the
template's parameter order.
Dozens of entries describe thousands of mangled symbols. This is where the manifest is genuinely cheaper than checking via headers.
Entry shape 3 — symbol: (rare; literal exact match)¶
Reach for this when the promise really is one specific mangled symbol
— a versioned entry point, a dlsym plugin name, a stable C ABI
function. Equality match against .dynsym.
version: 1
provides:
- symbol: oneapi_dal_version
library: libonedal_core.so.1
optional_provider: false
- symbol: _ZN6oneapi3dal9train_opsIfNS0_6methodE...
library: libonedal_core.so.1
optional_provider: false
You generally don't want this for templates — instantiation form is shorter, demangler-version-independent, and easier to review.
Shared fields¶
Every entry accepts:
library(optional) — required whenoptional_provider: false. Names a specific library (filename likelibcore.soor SONAME likelibcore.so.1both work).optional_provider(defaulttrue) — whentrue, any sibling in the bundle can satisfy the promise; whenfalse, the symbol must be provided by the namedlibrary. Must be a real boolean (true/false); strings like"false"and integers are rejected.
Exactly one of symbol / pattern / template per entry; mixing
raises a ValueError.
Verdicts¶
| Manifest entry status in new bundle | ChangeKind | Default verdict |
|---|---|---|
| No matching symbol | bundle_manifest_instantiation_removed |
BREAKING |
Matched but at wrong provider (when optional_provider: false) |
bundle_manifest_instantiation_removed |
BREAKING |
| Matched in new bundle but not in old bundle | bundle_manifest_instantiation_added |
COMPATIBLE (addition) |
A malformed manifest aborts the run with a ClickException. A failing
--instantiation-manifest is treated as a user error, not an environmental quirk —
unlike the bundle-engine-internal failures, which degrade to per-library
results with a warning.
Bootstrapping a manifest¶
Hand-writing the first manifest is the hard part. abicheck ships a helper that produces a starting point:
The script walks the release's .so files, demangles every exported
symbol, groups by top-level C++ namespace, and emits one pattern:
entry per (namespace, library) pair. The result is intentionally
over-broad — every symbol the bundle currently exports is promised.
A curator then narrows it:
- Drop entries for internal namespaces (
detail::,impl::). - Replace generic
ns::*patterns with specifictemplate:entries for explicitly-instantiated classes. - Mark experimental surface
optional_provider: true. - Delete entries for libraries that aren't part of the public contract (test fixtures, internal tooling shipped alongside the release).
You don't have to do this all at once. The minimal useful manifest is one entry per library covering the namespaces you actually want to freeze.
.abicheck.yml's bundle.system_providers:¶
The bundle layer needs to distinguish intra-bundle imports (a sibling should be providing this symbol) from external imports (the symbol comes from the system loader: libc, libstdc++, libgcc_s, libpthread, libtbb, libsycl, OpenCL, ...). The built-in allow-list handles the canonical set; this config key extends it.
When to use it:
- Your bundle uses an external SDK shipped outside the release tarball
(e.g. a vendor library like
libvpl.so.2that consumers install separately). - A
--instantiation-manifest-free workflow keeps emittingbundle_intra_dep_removedfindings against symbols you know are external.
Example (.abicheck.yml):
These sonames are appended to the built-in allow-list for every run in this
project — a stable, reviewed-in-a-PR property of the release, not a
per-invocation flag (CLI cleanup phase two, PR J; formerly
--bundle-system-providers libfoo,libbar, one run at a time).
--bundle-facts-out PATH¶
Persist the OLD side's bundle facts (per-library snapshots plus the
instantiation manifest, if any) to PATH for a later stored-baseline bundle
comparison (G38 Phase 2). See
Comparing against a stored bundle baseline
above. Additive output — it does not change this invocation's own findings
or exit code.
--no-bundle-analysis (the whole-run bundle-analysis opt-out this used to
be a no-op alongside) is gone: bundle-level analysis always runs now
(Phase 7d, one-comparison-product.md §4.1).
JSON output schema additions¶
compare -o json=... (on a bundle) adds two top-level keys when bundle
analysis ran:
{
"verdict": "BREAKING", // existing: worst of per-lib × bundle
"libraries": [...], // existing
"unmatched_old": [], // existing -- the raw set difference (unmatched, not removed)
"unmatched_new": [], // existing
"warnings": [], // existing
"comparison_scope": { ... }, // (schema 2.50): per-member acquisition record,
// completeness, policy, proven_removed/proven_added
"analysis_assurance": { ... }, // (release schema 1.3): the per-member assurance fold --
// present only under assurance.require_complete
"public_surface_reconciliation": { ... },// release schema 1.8: the product's ONE public
// contract reconciled against the union of its
// members' exports -- see below
"bundle_verdict": "BREAKING", // new
"bundle_findings": [ // new
{
"kind": "bundle_intra_dep_removed",
"symbol": "core_mul",
"consumer_library": "libalgo.so",
"provider_library": null,
"description": "libalgo.so imports core_mul, but no library in the new bundle exports it. Runtime load of libalgo.so will fail with undefined symbol.",
"old_value": null,
"new_value": null,
"affected_libraries": ["libalgo.so"]
}
]
}
bundle_findings is [] (empty list) when bundle analysis found nothing.
Bundle analysis is always attempted on a directory/package compare
now — there is no flag to opt out anymore (Phase 7d, one-comparison-
product.md §4.1). The keys are still omitted in two narrower cases that
predate this change and are unrelated to it: no libraries matched on
either side (and no manifest), or the bundle-level snapshot build itself
failed (a warning is printed; the run does not abort).
public_surface_reconciliation¶
A product's installed headers are one public contract; its DSOs are several providers of it. This block is that contract's own reconciliation, computed once for the release rather than once per member — see Products, Not Libraries § One public surface, many providers for why a per-member answer is a Cartesian product (787,833 findings on a 28-library Intel MKL release) rather than a stricter check.
{
"version": 1,
"evaluated": true,
"sides": {
"old": { ... }, // same shape as "new"
"new": {
"acquisition_key": "09ed55cd…", // WHICH public surface produced these numbers
"surface_resolvable": true, // false = no header evidence, NOT "promises nothing"
"public_declarations_with_export_obligation": 2,
"satisfied_by_bundle_exports": 2, // satisfied by ANY member
"missing_from_bundle": [], // no member provides it, coverage proven complete
"unresolved_under_incomplete_coverage": [], // a member was unread -- recorded, not concluded
"exports_total": 2,
"exports_declared_in_headers": 2,
"exports_not_declared_in_headers": 0,
"coverage_complete": true,
"coverage_reason": null // present when coverage_complete is false
}
},
"missing_exports": [ // one per declaration the whole bundle lacks
{
"kind": "public_not_exported",
"symbol": "api_b",
"scope": "release",
"description": "The release's public headers declare function 'api_b' …",
"old_value": "api_b",
"source_location": "include/product.h:6",
"cross_source_evolution": "introduced"
}
],
"shared_findings": [ // one product fact several members observed
{
"kind": "type_size_changed",
"symbol": "Cfg",
"description": "Size changed: Cfg (32 → 64 bits)",
"affected_libraries": ["libA.so", "libB.so"]
}
],
"undocumented_exports_by_member": {"libA.so": 0, "libB.so": 1},
"acquisition": {"acquisitions": 2, "reuses": 0, "keys": [ ... ]}
}
Four things to read it by:
- A missing export names no owning library. A symbol nothing in the bundle exports has no provider to attribute it to, so the finding carries the declaring header instead of an invented owner.
- An unread member narrows the conclusion. If a member's acquisition
failed, a declaration nobody else exports may simply live in the library
nobody read: it lands in
unresolved_under_incomplete_coverage,coverage_completegoesfalsewith acoverage_reason, and no missing-export finding is claimed. The successful evidence is kept. shared_findingsis de-duplication, not filtering. A fact several members report identically is emitted once with every affected library named, and eachlibraries[]entry records how many of its findings were folded there (product_level_findings). Per-library counts, verdicts and the exit code are unchanged by the fold;--output-dir's per-library reports stay full and unfolded.acquisition.acquisitionsis the instrumentation. One header acquisition per side for an ordinary comparison, however many members the release has; two sides passing the identical header request share one.
The same block appears in a stored bundle-facts comparison's document
(compare old-bundle-facts.json NEW_DIR and
compare old-bundle-facts.json new-bundle-facts.json), with two
differences that follow from there being nothing to acquire: acquisition
is empty rather than fabricated, and shared_findings is empty because
those documents already report one product-level finding rather than
repeating it per member. A stored side's contract is the one its capture
recorded (BundleFacts.public_surface, bundle-facts schema 4); a baseline
captured before that field existed has one derived from its stored member
snapshots, so it still reconciles. A side carrying no header evidence at
all records no contract and the block is omitted — it never borrows the
other side's, which would make every deliberately retired declaration read
as a missing export.
undocumented_exports_by_member is deliberately counts, not a symbol-keyed
map: a real product can carry hundreds of thousands of undocumented exports,
and the symbols themselves stay where they are already attributed — each
member's own exported_not_public findings, which remain per member because
the exporting member is the attribution.
Each bundle finding has:
kind— one of the ninebundle_*ChangeKind values (see Change Kinds reference).symbol— mangled symbol name (or library name forbundle_library_*findings).consumer_library— the sibling whose ABI is affected (nullable).provider_library— the sibling that caused the change (nullable).old_value/new_value— provider/version migration details when applicable.affected_libraries— list of every library affected by this finding; enables fan-out filtering downstream.
Per-library finding cap and truncation¶
The human release summary bounds what it itemizes: each library's rendered findings list (kind/symbol/description/location per finding) shows at most 10 entries, so a large directory/package fan-out cannot blow up a report a person is reading.
The machine document is never bounded. Each libraries[i] entry's
findings/findings_view list carries every finding, unconditionally —
there is nothing to configure, and nothing that can be configured into
truncating it. This used to be a decision (--max-findings-per-library N,
or an ABICHECK_MAX_RELEASE_FINDINGS_PER_LIBRARY environment variable);
both are retired, because the answer never depended on the caller: complete
data is what a machine export is for, and a bound is what makes a human
summary readable. Neither ever changed the verdict or the exit code.
Every library's own full, unfiltered report is one export away:
writes one complete per-library report plus summary.json into reports/,
alongside whatever other export you asked for.
When a library's list is actually truncated, the rendered entry sets
findings_truncated (or, under --view show=..., findings_view_truncated
swapped into that same key for the filtered display) and
findings_truncated_kinds — a ChangeKind -> count map of what was cut
from that library's displayed list, the same shape scan --against's
identical ledger uses — so the shape of a truncated library's diff is
visible without leaving the summary (and the machine export carries the
findings themselves in full). The map is absent when nothing
was truncated for the displayed view. (The release JSON's own top-level
release_filtered_summary block separately carries the exact, uncapped
total finding count across the whole release, for the aggregate case where
--view show=... is active.)
{
"libraries": [
{
"library": "libfoo.so",
"verdict": "BREAKING",
"breaking": 25,
"findings": ["... 10 entries ..."],
"findings_truncated": true,
"findings_truncated_kinds": {"func_removed": 24, "public_surface_shrank": 1}
}
]
}
Exit codes¶
Same as before, but a bundle finding can promote the verdict:
| Exit | Meaning |
|---|---|
| 0 | All clear — no per-library or bundle findings above COMPATIBLE_WITH_RISK |
| 2 | At least one library or bundle finding is API_BREAK |
| 4 | At least one library or bundle finding is BREAKING |
| 1 | No compatibility break, but the completeness axis contributed: .abicheck.yml's scope.on_incomplete: block with an unchecked member, or a run that completed no comparison at all (under either setting) |
| 8 | Library proven removed from the bundle (only with .abicheck.yml's gate.fail_on_removed_library: true, and only when NEW's inventory is proven complete — see Comparison scope and completeness) |
If you previously had a green CI on a release and bundle analysis now
flips it red, the finding section in the markdown / JSON tells you what
changed and which consumer is affected. The bisect path depends on which
finding fired: a per-library finding (something in libraries[].changes)
can be silenced with a suppression if it's expected; a
bundle_* finding cannot be suppressed today (see above) — your option is
to fix the intra-bundle contract, or (for a genuinely external provider)
.abicheck.yml's bundle.system_providers: as described below.
Comparison scope and completeness¶
A directory/package compare records, for every library discovered on
either side, what happened to it in this run — its acquisition state
— separately from any verdict:
| State | Meaning |
|---|---|
available |
Both sides supplied it and the comparison completed |
not_supplied |
One side has no counterpart, and the lacking side's inventory is not proven complete — so it is unmatched, not removed/added |
unsupported |
An artifact this build cannot analyze (a stored snapshot newer than this reader, a container format with no backend) |
failed |
Its extraction or comparison failed (also an operational ERROR) |
out_of_scope |
Not selected by this run (see below); contributes nothing |
The JSON report carries the whole record under comparison_scope
(members, states, reasons, each side's inventory proof, and the proven
proven_removed/proven_added sets), run_outcome.scope reads
complete/incomplete, and the Markdown report and PR comment state the
unchecked members and that the verdict covers the compared members only.
One candidate against a many-member baseline. When NEW is named as a
single file (abicheck compare baseline/ build/libfoo.so) with exactly one
OLD counterpart (and NEW's inventory is not proven complete), the run is a
current-artifact comparison: the other OLD members are out_of_scope, the
scope is complete, and nothing is reported as removed — a local
single-variant build compared against a twelve-variant baseline no longer
reads as eleven removals. The intent has to be in the operand shape: the
same one library supplied as a directory is treated like any other
directory, so the eleven unmatched members are unchecked and
.abicheck.yml's scope.on_incomplete: block still gates. Otherwise a pull request that
controls the NEW tree could trim it to one library and turn a blocking
incomplete scope into a clean pass.
Policy. .abicheck.yml's scope.on_incomplete: warn (the default) reports every
unchecked member and contributes 0 to the exit code; block contributes
1, folded with max exactly like the contract-coverage axis (a clean 0
becomes 1; a real 2/4 is never lowered). A run that completed no
comparison at all exits 1 under either setting: a permissive policy can
downgrade missing members, never "nothing compared".
Removals need proof. .abicheck.yml's gate.fail_on_removed_library: true exit 8 fires only
for a proven removal: NEW's inventory must be proven complete, which in
this release means a stored baseline whose capture asserted it — a
--bundle-facts-out document, or a ProjectSnapshot package imported from
one, carrying inventory_complete: true (the fan-out asserts it when its
capture covered every library the OLD release enumerated and .abicheck.yml's release.dso_only: true
left none unclassified). Being stored proves nothing by itself: a package
or document without the assertion is as unproven as a live directory, and
the same document decides identically whether compared directly or after
import into a package.
A package archive operand proves it too: an archive
extractor unpacks its whole container or raises, so package.py now returns
a declared component inventory beside the extracted directory
(package_component_inventory) and its complete flag is that statement.
A component the archive plainly ships but whose content the extracted tree
cannot reach — a dangling link, an unreadable file — is expected but not
produced, an acquisition failure on the completeness axis, never an absence
the other side's proof may read as a removal.
The JSON key unmatched_old lists the members with no counterpart, as its
name says — read off the acquisition record, which replaced
the old-minus-new set difference it used to be computed from. The fan-out's
stderr notices come from that same record, so library removed: X (naming
the inventory that proved it) and library unmatched (no counterpart on
NEW): X are now two different lines rather than one wording for both. See
the migration notes in
Exit codes.
Analysis assurance across a bundle¶
The completeness axis above asks whether every selected member was compared at all. A second, independent question is whether the comparisons that did run had complete enough evidence to trust — and a release has one answer per compared member, not one for the whole run.
.abicheck.yml's assurance.require_complete: true turns that into a gate
here the same way it already did for a single pair. The release's contribution
is max over every compared member's own: any member whose
analysis_assurance.status is not complete floors the release, no matter how
many fully-analysed siblings it has, and over a one-member package the fold
is the identity — so that package gates and reports exactly as comparing its
one library on its own would.
Under the setting the JSON report carries the fold as analysis_assurance
(aggregate status, how many members were folded, how many fell short, the
0/1 exit contribution, and an incomplete_members list naming each short
member and the notes explaining why), and each libraries[] entry carries
its own analysis_assurance_status. A non-JSON format gets the same facts as a
one-line stderr notice. A per-component export's summary.json carries the block and
folds the axis into its own exit block, so it cannot report a different exit
code than the run took. A stored BundleFacts operand folds identically.
The two axes are orthogonal and either can hold without the other: every
member compared but one missing its headers is scope complete with a
partial analysis; one member never supplied while the rest were fully
analysed is scope incomplete with a complete analysis over what ran. Both
contribute 0/1, both fold with max, and when they tie the exit block's
reasons names both. Without assurance.require_complete the axis contributes
0 and the report carries no analysis_assurance key at all, so a run that
never opted in is unchanged. See
Exit codes.
Support-promise findings (release.support_promise in .abicheck.yml).
A proven inventory change is a change to what the project promises to ship, so it must be emitted under a policy rather than
inferred — a stable project property, not a per-invocation flag, so
one-comparison-product.md Phase 7i moved it out of the CLI. off (the
default) emits nothing. release: {support_promise: declared} reports each proven
addition as support_promise_component_introduced (a compatible addition),
as ordinary entries in the release report's libraries list carrying a
support_promise field, so the release verdict, the severity policy and
every renderer see them like any other finding. Unlike
bundle_library_removed, which fires only when a surviving sibling in the
same release imports the missing library, a retired promise holds for a
component with no intra-bundle consumer at all. An unmatched member under an
unproven inventory never produces one, whatever the setting.
Stored baselines. A --bundle-facts-out capture whose stranded library
failed to dump records that member under degraded_members (with the
failure) instead of persisting an ELF-only stand-in as if it were complete;
a later stored/stored or stored/live comparison skips such a member, says so
in bundle_analysis_errors, and records it as failed on the completeness
axis; a stored ProjectSnapshot package carries the same marker, and the
directory/package compare fan-out skips a marked member on either side and
reports it with verdict failed. Such a document declares schema_version: 3 (a clean document keeps
2), so an older abicheck rejects it instead of comparing the stand-in as
real evidence. The stored/stored and stored/live drivers also record a
matched pair whose extraction contracts disagree per member,
as the fan-out does: that member is failed on the completeness axis with
a not comparable reason, listed under not_comparable_members in the
JSON document, its siblings' comparisons are kept, and the run exits 16
with run_outcome.operational: not_comparable. A stored pair with no
library in common renders its comparison_scope (proven removals and
additions included, under asserted inventories) and exits 1 through the
no-comparison rule rather than failing as a usage error.
Comparing against a stored bundle baseline (G38 Phase 2)¶
Every bundle comparison above reopens live .so files on both sides. That
means a stored-baseline workflow — the normal scan --against/CI pattern
every other surface this tool supports — could not get a bundle-level
verdict at all: there was no persisted form of "what the bundle layer knows
about a release" to compare a live directory against later.
--bundle-facts-out PATH on the directory/package compare fan-out closes
that gap. It persists the OLD side's per-library snapshots (the same
AbiSnapshots that run already produced) plus the instantiation manifest,
if any, to PATH as a BundleFacts file — additive output alongside the
ordinary live-vs-live comparison the invocation already performs; it changes
no finding or exit code.
# Capture release-1.0's bundle facts while doing an ordinary comparison.
abicheck compare release-1.0/ release-2.0/ -H include/ \
--bundle-facts-out release-1.0.bundlefacts.json
# Later, get a bundle-level verdict for release-1.0 -> release-3.0 without
# ever reopening release-1.0's binaries.
abicheck compare release-1.0.bundlefacts.json release-3.0/
OLD_INPUT being a stored BundleFacts document (from a prior
--bundle-facts-out run) is detected automatically, the same way compare
already classifies a directory vs. a package vs. a single binary — there is
no separate flag to pass. NEW_INPUT can be either a live release
directory/package (extracted the same way the ordinary release fan-out
extracts one) or a second stored BundleFacts document — comparing two
previously-captured documents with no binaries reopened on either side, e.g.
abicheck compare release-1.0.bundlefacts.json release-2.0.bundlefacts.json.
Only -o json=.../markdown are available in either mode, and most of
the ~44 flags the live release fan-out accepts have no channel into a
stored-facts comparison and are rejected explicitly (exit 64) rather than
silently ignored — see abicheck compare --help-all for the full, current
list. When NEW_INPUT is also stored, every flag that would only ever
apply to a live NEW_INPUT (--header/--include, --ast-frontend,
--compiler/--sysroot, a -H new= development package,
--bundle-facts-library-manifest, an explicit --version new=, ...) is
rejected too, alongside a mismatched build variant between the two
documents (BundleFacts.variant_fingerprint, a stable build-identity
fingerprint each captured document carries): two documents captured from
different logical builds (a CPU-only build vs. a SYCL/DPC build of the
same source tree, say) are refused outright rather than silently diffed as
if they were the same release. Internally, OLD-stored/NEW-live routes
through
abicheck.bundle_side_input.compare_release_against_bundle_facts() (the
same driver the paragraph below describes); OLD-stored/NEW-stored routes
through abicheck.workflows.bundle_stored_pair_compare.
compare_stored_bundle_facts_pair().
compare_bundle_from_facts() reconstructs a live-equivalent
BundleSnapshot from the stored per-library AbiSnapshot.elf metadata (no
binaries read) and then delegates to the exact same compare_bundle() a
live-directory comparison uses — so the two entry points can never
independently drift, and a stored-facts comparison produces byte-identical
findings to a live one for the same underlying facts.
Per-library header/include/compile-context overrides (G38 Phase 17)¶
A stored-bundle-facts-OLD_INPUT compare normally applies one uniform
--header/--include/compile-context to every library in NEW_INPUT —
fine when the whole bundle shares one toolchain, but not for a bundle built
with more than one (e.g. a plain-C++ library alongside a -fsycl/icpx
DPC++ one sharing an umbrella header tree). --bundle-facts-library-manifest
PATH names a YAML/JSON file giving individual libraries their own header
root, include path, or compile context instead:
# manifest.yaml
libonedal_dpc.so:
headers: [include/oneapi/dal/dpc]
gcc_path: icpx
gcc_options: ["-fsycl", "-DONEDAL_DATA_PARALLEL"]
sysroot: /opt/intel/oneapi/sysroot
abicheck compare release-1.0.bundlefacts.json release-3.0/ \
--bundle-facts-library-manifest manifest.yaml
A library not named in the manifest keeps the uniform --header/
--include/compile-context fallback unchanged. A manifest entry naming a
library outside the bundle (a typo, or a library that was renamed/removed)
is a hard error, not a silent no-op. The flag is meaningless — and rejected
— unless OLD_INPUT is a stored BundleFacts document.
Platform support¶
Bundle analysis is ELF/Linux-only. Mach-O and
PE/COFF bundles are out of scope for this iteration — the resolution
graph relies on DT_NEEDED edges and .gnu.version_r / .gnu.version_d
sections that PE and Mach-O don't have direct equivalents for. On
non-Linux runs, compare skips bundle analysis silently and
emits per-library results only.
Programmatic API¶
The bundle layer is also exposed as a Python module for downstream tooling:
from abicheck.bundle import (
build_bundle_snapshot, compare_bundle, load_manifest,
)
from pathlib import Path
old = build_bundle_snapshot({p.name: p for p in Path("old/").glob("*.so")})
new = build_bundle_snapshot({p.name: p for p in Path("new/").glob("*.so")})
manifest = load_manifest(Path("manifest.yaml")) # optional
# per_library_results is the list of DiffResult returned by
# abicheck.checker.compare() for each library pair.
result = compare_bundle(old, new, per_library_results, manifest=manifest)
print(result.bundle_verdict) # Verdict.BREAKING / COMPATIBLE / ...
for f in result.bundle_findings:
print(f.kind, f.symbol, f.consumer_library)
For a stored-baseline comparison (G38 Phase 2 — see above), swap the OLD
side for a loaded BundleFacts and compare_bundle_from_facts():
from abicheck.bundle import discover_artifact_set
from abicheck.workflows.bundle_facts_compare import compare_bundle_from_facts
from abicheck.package import discover_shared_libraries
from abicheck.serialization import load_bundle_facts
old_facts = load_bundle_facts("release-1.0.bundlefacts.json")
# discover_shared_libraries() walks the directory and identifies real ELF
# shared objects by content -- unlike a plain glob("*.so"), it also finds a
# runtime-only versioned DSO (libfoo.so.1, no unversioned dev symlink).
# discover_artifact_set() then canonicalizes each discovered filename the
# same way write_bundle_facts_out() keyed old_facts.per_library_snapshots --
# a plain {p.name: p for p in ...} comprehension would key that same
# libfoo.so.1 as a different library from the persisted "libfoo.so" and
# misreport it as removed.
new = build_bundle_snapshot(
discover_artifact_set(
discover_shared_libraries(Path("release-3.0/")), explicit=False
)
)
# per_library_results still comes from diffing each library's stored
# AbiSnapshot (old_facts.per_library_snapshots[name]) against a freshly
# resolved new-side snapshot, e.g. via abicheck.service.compare_snapshots().
result = compare_bundle_from_facts(old_facts, new, per_library_results)
References¶
- Example cases:
case90_bundle_intra_dep_removed— intra-bundle removed symbol,case91_bundle_intra_signature_drift— extern-C signature drift,case92_bundle_provider_changed— provider migration,case93_bundle_manifest_drift— manifest drift