Skip to content

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 libcore to libutil is 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 only libcore.
  • 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:

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

Set .abicheck.yml's gate.fail_on_removed_library: true to make a proven removal exit 8 instead of an incomplete-scope warning:

gate:
  fail_on_removed_library: true

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's bundle.cohorts:; fixture case84_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_removed among 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_surface existed 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 compat path 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_NEEDED edges 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.yml runs 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