Skip to content

Case 150: Bidirectional Export ↔ Declaration Pair

Field Value
Verdict 🟢 COMPATIBLE
Category Quality (Compatible)
Classification Scenario — Capability / evidence demonstration · audit
Platforms Linux
Flags Bad practice
Detected ChangeKinds exported_not_public, public_not_exported
Source files catalog/cases/case150_xcheck_export_public_pair/
Related rules audit-accidental-export, audit-public-not-exported
Subject Export/declaration mismatches

Category: Quality (Audit) | Verdict: 🟢 COMPATIBLE (bad practice)

Verdict and consumer impact

Single-release audit: one build's evidence checked against itself, no baseline. abicheck reports no verdict at all ("verdict": null): a single build has nothing to be compatible with. (The catalog's 🟢 COMPATIBLE classification above describes the case, not the command's output: the ABI hasn't broken.) But the audit flags two advisory findings that are the two failure directions of the same L0-exports ↔ L2-decls contract:

Direction Symptom Cross-check
exported, undeclared internal() is in the binary's export table but no public header declares it exported_not_public
declared, unexported public_api() is declared in include/demo/api.h, but a stray static kept its definition out of the export table public_not_exported

A consumer reading only the public headers believes public_api() is callable — it isn't, the symbol doesn't exist in the .so. A consumer poking at the exported symbol table finds internal() — nothing documents it, so any layout or behavior change to it is invisible in the public API surface. Both are contract mismatches between what the library documents and what it ships, and this case trips both directions in one build.

What this snapshot contains

snapshot.abi.json is a single, hand-built AbiSnapshot for one build of libdemo.so, carrying both the binary's export table and the public-header declaration set:

Source in the snapshot What it records
Binary export table (L0, elf.symbols) _Z8internalv (internal) exported with default visibility; _Z10public_apiv (public_api) absent
Public-header AST (L2, functions[]) public_api declared in the public header; internal not declared anywhere public

abicheck command

abicheck compare --no-baseline snapshot.abi.json

compare --no-baseline, not scan

0.6 makes this the declared spelling for a single-build audit, and retires scan. This case was blocked on that migration until 2026-09-09; the audit now reports the finding below directly, and tests/parity/test_no_baseline_audit_corpus_parity.py pins that it reports at least every check scan does, counted per finding kind, while manufacturing no comparison of its own (no verdict, no changes[] entry).

Expected abicheck finding

# ABI audit: libdemo.so (no baseline)

OLD side: **declared absent** (`--no-baseline`) -- this is an audit of the candidate build alone, not a compatibility comparison. No additions, removals, or compatibility verdict are reported.

- Candidate version: `1.0`
- Acquisition state (OLD): `declared_absent`
- Evidence tiers: elf, header

## Candidate-side findings

| Finding | Symbol | Severity | State | Detail |
| --- | --- | --- | --- | --- |
| `exported_not_public` | `_Z8internalv` | potential_breaking | present in this build | Symbol '_Z8internalv' is exported by the binary but declared in no public header (declared as function 'internal' in a non-public header). It is accidental ABI surface — hide it (visibility/version script) or document it. |
| `public_not_exported` | `_Z10public_apiv` | potential_breaking | present in this build | Public header declares 'public_api' (expected symbol '_Z10public_apiv') but the binary does not export it. Code that compiles against the header gets an undefined-symbol link error. |

Minimum evidence

min_evidence: L2 — the binary export table (L0) alone sees only which symbols exist, with no notion of what's "public"; the public-header AST (L2) is what supplies the declared-API boundary that exported_not_public and public_not_exported cross-check the export table against.

Why abicheck catches it

Each direction needs both sources: the binary export set and the public-header declaration set. exported_not_public flags an export with no matching public declaration; public_not_exported flags a public declaration with no matching export. Neither check can fire from either source alone — the export table has no notion of "declared", and the header AST has no notion of "exported" — the cross-check is what makes the mismatch visible, in both directions symmetrically.

Why this matters for a real release

internal() shipping in the export table without a public declaration means the maintainer can change or remove it without warning — but some consumer, reading the .so's symbol table directly (common with dlsym or reverse-engineered bindings), may already depend on it as if it were stable. public_api() being documented but absent from the exports is the opposite failure: any consumer that follows the header and calls it gets a link error the moment they try, not a silent bug — but it means the library's own documented contract doesn't match what it ships, caught here before a consumer files that bug report.

Safe redesign

  • internal(): hide it (version-script local: scoping or hidden visibility) or add a public declaration for it if it's genuinely meant to be callable.
  • public_api(): remove the stray static so the definition is actually exported, or drop the declaration from the public header if it was never meant to ship.

Cross-tool comparison

The exported_not_public / public_not_exported pair is a cross-source check unique to abicheck's audit mode — it reconciles a build's own export table against its own public-header declaration set within the same build, which isn't something abidiff/abi-compliance-checker do (they diff two ABI dumps against each other, not a binary's exports against its own headers).


Source files

  • snapshot.abi.json

See also: Compatibility Catalog · All COMPATIBLE cases · Category: Quality (Compatible) · Subject: Export/declaration mismatches.