Skip to content

Case 160: Public API Gains an Internal Dependency

Field Value
Verdict ๐ŸŸก COMPATIBLE_WITH_RISK
Category Risk
Platforms Linux
Flags Bad practice
Detected ChangeKinds public_api_internal_dependency_added
Source files examples/case160_public_api_internal_dep_added/

Category: Build/Source Evidence (L5) | Verdict: ๐ŸŸก COMPATIBLE_WITH_RISK

This case ships a hand-built pair of evidence-model fixtures (old.json + new.json โ€” SourceGraphSummary dumps) instead of compilable v1/v2 sources, so the corpus is also validated compiler-free by tests/test_l3l4l5_examples.py (which calls diff_source_graph_findings on the same two files directly). See scripts/gen_l3l4l5_examples.py for how they were generated.

Verdict and consumer impact

In the derived L5 source graph, the exported entry demo::parse() newly calls the internal (non-public-header) helper detail::validate() that it did not reach in v1. Nothing about the shipped ABI changes โ€” demo::parse's symbol, signature, and behavior contract are all stable โ€” so this is not a compatibility break for existing consumers today. It is a risk: the public surface has quietly taken on an undeclared dependency on an internal declaration, so a later, unreviewed change to detail::validate() can change demo::parse()'s behavior without looking like an API change at all.

Old/new diff

v1 source graph v2 source graph
demo::parse() declared by include/demo/api.h; maps to exported symbol _ZN4demo5parseEv same declaration/symbol mapping, unchanged
detail::validate() โ€” private-header declaration, no incoming DECL_CALLS_DECL edge from demo::parse() new edge: demo::parse() --[DECL_CALLS_DECL]--> detail::validate()

abicheck command

# old.json / new.json here are this case's committed L5 source-graph
# fixtures (SourceGraphSummary dumps, as produced by build_source_graph over
# a real source checkout) -- not AbiSnapshot files, so they're supplied to
# abicheck as an out-of-band --build-info pack rather than as compare's
# positional inputs.
mkdir -p pack_old/graph pack_new/graph
echo '{"build_source_pack_version": 1}' > pack_old/manifest.json
echo '{"build_source_pack_version": 1}' > pack_new/manifest.json
cp old.json pack_old/graph/source_graph_summary.json
cp new.json pack_new/graph/source_graph_summary.json

# Nothing changed at the binary/header level between v1 and v2, so a pair of
# otherwise-empty snapshots stands in for the (unchanged) artifact side.
python3 -c "
from abicheck.model import AbiSnapshot
from abicheck.serialization import save_snapshot
save_snapshot(AbiSnapshot(library='libdemo.so', version='1.0'), 'empty_old.json')
save_snapshot(AbiSnapshot(library='libdemo.so', version='2.0'), 'empty_new.json')
"

abicheck compare empty_old.json empty_new.json \
  --build-info old=pack_old --build-info new=pack_new

Expected abicheck finding

Verdict: COMPATIBLE_WITH_RISK (exit 0)

- public_api_internal_dependency_added: Public entry demo::parse() now
  reaches internal declaration detail::validate() it did not reach in v1
  > The public surface has taken on an undeclared dependency; a later
    change to that internal entity becomes a hidden risk. Proof path:
    demo::parse() --[DECL_CALLS_DECL]--> detail::validate()

Also reported (quality signal, not verdict-affecting):
- call_graph_public_entry_reachability_changed: reachable-callee count
  for demo::parse() changed (1 -> 2 known static callees, approximate)

Minimum evidence

min_evidence: L5 โ€” detail::validate() has no public declaration and no distinct exported symbol of its own, so an artifact diff (L0/L1) shows demo::parse's exported symbol completely unchanged, and a header-AST diff (L2) shows its public declaration unchanged too. Only the derived L5 call graph โ€” built from linked source-ABI facts plus DECL_CALLS_DECL edges โ€” reveals that reachability from the public entry point actually grew.

Why abicheck catches it

build_source_graph links each translation unit's declarations, calls, and symbol mappings into one SourceGraphSummary per release; diff_source_graph_findings compares the two graphs' edge sets and reports public_api_internal_dependency_added whenever a public entry gains a new DECL_CALLS_DECL/reference/field/base/ parameter-type edge to a declaration whose visibility resolves to private_header (or otherwise non-public). This is the version-over-version analogue of the intra-version public_to_internal_dependency cross-check that flags the same shape within a single release.

Build/deployment scenario

This is what a source-graph audit across two release checkouts (built from compile_commands.json plus a clang-based collector, or a captured abicheck_inputs/ pack) would catch during code review: a previously self-contained public entry point now statically reaches a header that ships under a private include path. No build or deployment step fails today โ€” the risk is that detail::validate()'s owners can change its behavior assuming it's purely internal, without realizing a public entry point's contract now depends on it.

Safe redesign

Either promote the internal helper to a documented, stable part of the API (so its evolution is reviewed with the same care as the public entry point), or keep the public entry's implementation independent of internals whose contract consumers cannot observe or pin.

Real-world example: libraries that maintain a detail::/impl:: namespace typically document it as unstable-by-convention and audit which public entry points call into it before every release, precisely to catch this kind of unreviewed coupling before it ships.

Cross-tool comparison

Neither abidiff nor abi-compliance-checker have an operand here: both tools diff compiled binaries (plus optional DWARF/headers), and a purely internal reachability change is โ€” by construction โ€” invisible at that layer, since demo::parse's exported symbol and signature never move. The whole reason this needs L5 source-graph evidence is that no binary or header-AST-only diff can see a new call edge between two declarations. There is no .so/abidw XML pair to hand either tool for this case.


Source files

  • new.json
  • old.json

See also: Examples overview ยท All COMPATIBLE_WITH_RISK cases ยท Category: Risk.