Skip to content

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:

abicheck compare release-1.0/ release-2.0/ -H include/

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-manifest covers 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.cpp files, 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.30 guarantees 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 when optional_provider: false. Names a specific library (filename like libcore.so or SONAME like libcore.so.1 both work).
  • optional_provider (default true) — when true, any sibling in the bundle can satisfy the promise; when false, the symbol must be provided by the named library. 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:

python scripts/extract_bundle_manifest.py release-2.0/lib/ > manifest.yaml

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 specific template: 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.2 that consumers install separately).
  • A --instantiation-manifest-free workflow keeps emitting bundle_intra_dep_removed findings against symbols you know are external.

Example (.abicheck.yml):

bundle:
  system_providers: [libvpl.so.2, libcuda.so.1]
abicheck compare old/ new/

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_complete goes false with a coverage_reason, and no missing-export finding is claimed. The successful evidence is kept.
  • shared_findings is de-duplication, not filtering. A fact several members report identically is emitted once with every affected library named, and each libraries[] 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.acquisitions is 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 nine bundle_* ChangeKind values (see Change Kinds reference).
  • symbol — mangled symbol name (or library name for bundle_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:

abicheck compare release-1.0/ release-2.0/ -o json=reports/

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