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โSourceGraphSummarydumps) instead of compilablev1/v2sources, so the corpus is also validated compiler-free bytests/test_l3l4l5_examples.py(which callsdiff_source_graph_findingson the same two files directly). Seescripts/gen_l3l4l5_examples.pyfor 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.jsonold.json
See also: Examples overview ยท All COMPATIBLE_WITH_RISK cases ยท Category: Risk.