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¶
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-scriptlocal:scoping or hidden visibility) or add a public declaration for it if it's genuinely meant to be callable.public_api(): remove the straystaticso 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.