Products, Not Libraries¶
A product is one contract¶
A release that ships several shared libraries is not several contracts; it
is one. libalgo.so imports core_mul from its sibling libcore.so, so
the symbol is public inside the product whether or not any user ever
calls it: remove it from libcore and a per-library comparison says
libcore broke and libalgo is unchanged, which is true and useless — the
product no longer loads. Three things about a product cannot be seen one
library at a time:
- Which library provides a symbol. A symbol that moves from
libcoretolibutilis a removal in one report and an addition in another; seen as a product it is a provider change, compatible for a consumer that links both and a break for one that links onlylibcore. - The SONAME cohort. Libraries versioned together (
libfoo_core.so.3,libfoo_algo.so.3) promise to move together; one member bumping its SONAME alone is a skew no single report can call. - What the release promises. An explicit instantiation the build system emits is either part of the contract or an accident of the compiler, and only a declaration of the promised set can tell the two apart.
The bundle layer adds these cross-library findings on top of the per-library ones and never hides any of the latter; the product verdict is the worst of both.
Three shapes¶
| Your libraries… | Shape | Checked as | Declared with |
|---|---|---|---|
| depend on one another and ship as one release | release bundle | one comparison, one report, cross-library findings | .abicheck.yml bundles: grouping the member targets — S14 |
| are built together but do not depend on one another | independent targets | one comparison per target, no cross-library claim | targets: only — S15 |
| live in one repository with separate release cadences | monorepo components | per-component targets, each with its own baseline channel | S25 |
The declarative form is the project integration layer; each scenario page carries the exact YAML, and this page does not restate it. One rule worth knowing before choosing: a bundle check in the declarative topology is binary-depth only, because a bundle baseline stores its members' staged binaries and no per-member header or build evidence (S14 § Depth is binary-only). A member that needs header or source depth declares its own check beside the bundle's.
Run it¶
Two directories are a product comparison:
Set .abicheck.yml's gate.fail_on_removed_library: true to make a proven
removal exit 8 instead of an incomplete-scope warning:
Every shared object discovered in both trees is compared as a pair, a
library present only on the old side is reported as removed when NEW's
inventory is proven complete (exit 8 when the config key above is true;
otherwise it's an incomplete scope, governed by scope.on_incomplete), and
the cross-library pass runs over the whole set. The
JSON report carries the per-library results under their library names and
a bundle block for the cross-library findings and the bundle verdict;
Markdown renders the latter as a "Bundle (Cross-Library) Findings" section.
Field names and the exit-code table are owned by
Multi-binary § JSON output schema additions.
The five cross-library findings¶
- SONAME skew — one member of a declared cohort changed its SONAME
while its siblings kept theirs. Declared with
.abicheck.yml'sbundle.cohorts:; fixturecase84_bundle_soname_skew. - Intra-bundle dependency removed — a symbol one sibling imports from
another disappeared from the provider. The consumer library is
byte-identical and per-library clean; the finding lands on it, with
the provider named (
case90). - Intra-bundle signature drift — an
extern "C"function kept its name and changed its parameters, so the consumer's unchanged call now passes the wrong arguments (case91). - Provider changed — a symbol moved between siblings and the bundle
still exports it exactly once: risk, not a break, and only a product
view can say so (
case92). - Manifest drift — an instantiation the release promised is no
longer exported. Per-library this is one more
func_removedamong many; against the manifest it is a broken promise (case93).
The four bundle fixtures have no generated case pages; they are described in Multi-binary § References.
Declaring what the bundle promises¶
--instantiation-manifest names the symbols the release publicly promises, in three
entry shapes: a pattern: over demangled names (one line freezes a
namespace), template: with an instantiations: matrix (the shape a
template library needs — Template- and Header-Heavy Libraries
is about exactly that), and a literal symbol:. Two more .abicheck.yml
keys shape the resolution graph: bundle.system_providers: for libraries
the bundle imports from but does not ship (libc, libstdc++ and their kin
are built in; add your own), and bundle.cohorts: for the SONAME cohort
above. The file format, verdict rules and a bootstrap script that
produces a first over-broad manifest from a release are owned by
Multi-binary § --instantiation-manifest.
Comparing against stored facts¶
A live two-directory comparison needs both releases' binaries on disk. Capture the old side's bundle facts once, then compare later releases against the stored document without reopening the old binaries:
# release 1.0 -> 2.0, live on both sides, and persist 1.0's facts
abicheck compare release-1.0/ release-2.0/ -H include/ --bundle-facts-out bundle-1.0.json
# release 1.0 -> 3.0, without release-1.0/ on disk (OLD_INPUT is detected
# automatically as a stored BundleFacts document)
abicheck compare bundle-1.0.json release-3.0/ -H include/
A product whose members share one include tree but not one toolchain gives
individual libraries their own header root or compile context through
--bundle-facts-library-manifest
(Multi-binary § Comparing against a stored bundle baseline).
One public surface, many providers¶
A product's installed headers are one public contract; its DSOs are several
providers of that contract. That distinction has to be made explicitly,
because the natural implementation gets it wrong: compare the complete
product header surface against each library in turn and every declaration
some other library provides looks missing from this one. On a 28-library
Intel MKL release that produced 787,833 public_not_exported findings and
a 1.6 GB report — a ScaLAPACK declaration such as BDLAAPP demanded from
libmkl_rt although a sibling MKL library exports it — with no real
compatibility signal anywhere in it.
So a directory or package comparison splits the two questions:
| Question | Answered | Why there |
|---|---|---|
| Did this binary's ABI change? | per matching library | its exports, layouts and symbols are its own |
| Does the product export what its headers promise? | once, at release level | satisfied by any member, so no single member can answer it |
| Is an export undocumented? | per exporting library | the exporting member is the attribution |
| Did the public headers change? | once, at release level | one surface, so one set of findings |
The report gains a public_surface_reconciliation section (JSON) and a
Release public surface section (Markdown) stating, per side: how many
public declarations carry an export obligation, how many the bundle's
exports satisfy, which are missing, the export totals and the
documented/undocumented split, the per-member undocumented-export counts,
and how many times the header surface was acquired — one acquisition per
side for an ordinary comparison, however many members it has.
Three properties are worth knowing because they are what keep the answer honest rather than merely smaller:
- A declaration absent from the whole bundle is still a finding, once, at release level, carrying the declaring header — and deliberately no owning library, because a symbol nothing exports has no provider to attribute it to.
- An unread member narrows the conclusion instead of inventing one. If a member's acquisition failed, a declaration nobody else exports might simply live in the library nobody read: it is reported as unresolved under incomplete coverage, with the coverage gap named, and no high-confidence missing-export finding rests on evidence that was never read.
- Nothing is filtered to shrink the report. A change several libraries
report identically — a changed public type is the usual case — is one
product-level fact, so it is rendered once, naming every affected
library, and each member entry records how many of its findings were
folded there (
product_level_findings). Per-library counts, verdicts and the exit code are unchanged by that fold.
You do not maintain a header-to-library mapping for any of this. There is
no configuration: the union of the members' exports is the evidence, and
--exclude-header and the compile context apply to the one shared
acquisition exactly as they already applied per member.
Every multi-library comparison, not just a release directory¶
The same model answers the same question on every path that compares more than one library at once, so which command you reached it through cannot change whether a sibling's declaration counts:
| How you compare | Where the product contract comes from |
|---|---|
compare OLD_DIR NEW_DIR (directory or package) |
acquired once per side from the run's own headers |
compare old-bundle-facts.json NEW_DIR |
recorded in the stored document for OLD; acquired from the live NEW dump |
compare old-bundle-facts.json new-bundle-facts.json |
recorded in each stored document |
compat check with a multi-library descriptor |
the union of the descriptor's own libraries' header evidence |
Two consequences are worth stating, because they are what stop the shared model from over-reaching:
- A stored baseline's contract is the one it recorded, not a
re-derivation from this build's defaults — which is what keeps a stored
comparison's answer identical to the live run that produced the
baseline. A baseline captured before
BundleFacts.public_surfaceexisted has one derived from its members instead, so it still reconciles rather than silently losing its contract. - A side with no header evidence records no contract — it does not
borrow the other side's. A NEW release dumped at binary depth promises
nothing this tool can see, and asserting that it still promises whatever
OLD did would turn every deliberately retired declaration into a missing
export. On the
compatpath a descriptor that declares no headers likewise yields no release-level finding at all.
The compat path has no release JSON envelope of its own, so its
release-level findings are folded into the merged ABICC-shaped result
rather than a separate section.
Fan-out and fan-in¶
In CI a product runs one check per target and folds the reports into one
gate with aggregate. The fold has to know which reports it expected:
# one --build-output per contract profile the checks: block names
abicheck project plan .abicheck.yml \
--build-output linux-gcc=abicheck-build/linux-gcc -o plan.json
abicheck aggregate reports/ --manifest plan.json
The plan resolves every checks: entry against each named profile's
build output, which is why it needs one --build-output per profile: a
profile with no build output cannot resolve a check, and declared checks
that resolve to nothing exit 1 rather than emitting an empty target set
that would let the fold pass having checked nothing. A config that declares
no checks at all is the other case entirely — a project bootstrapping
.abicheck.yml — and produces an explained skipped plan (exit 0, with a
skipped block naming the reason and pointing at project validate). Without a declared target set — one --manifest, holding either the
project plan's own run-plan.json (the declarative form) or a hand-written
expected-target manifest; aggregate recognizes which from the document's
own schema — a missing report and an
intentionally absent one look identical, so a bare aggregate reports/
exits 64 rather than guess; --discovered-only is the explicit opt-out
that gates on whatever is present. A report that never arrived must be a
failure, not an absence: a job that crashed before writing its report
would otherwise pass the product. The five aggregation axes and the gate
block are owned by Aggregate Reports; the
topology schema by the
Project Targets Schema.
What the bundle layer cannot do today¶
- ELF only. The resolution graph is built from
DT_NEEDEDedges and GNU version sections; on PE and Mach-O the cross-library pass is skipped and only per-library results are reported (Multi-binary § Platform support). - Binary depth in the declarative topology. A bundle check in
.abicheck.ymlruns at binary depth; header and source evidence are per member, through the member's own check (S14, above). - Per-member header evidence is still stored per member. The release's
public surface is acquired and reconciled once, and a stored bundle
baseline records it once (
BundleFacts.public_surface, schema 4) — but each member snapshot still carries its own copy of the header evidence its dump parsed, so a stored product baseline is larger than the product contract needs it to be. - JUnit carries no release-level contract section. The release-level public-surface reconciliation is in the JSON and Markdown renders; the JUnit projection still reports per-member test cases only.
Ladder: ← Triage a Suspicious Finding · Step 8 · At Scale · Template- and Header-Heavy Libraries →