Case 181: Public API Reaches an Internal Declaration¶
| Field | Value |
|---|---|
| Verdict | ๐ข COMPATIBLE |
| Category | Quality (Compatible) |
| Platforms | Linux |
| Flags | Bad practice |
Detected ChangeKinds |
public_to_internal_dependency |
| Source files | examples/case181_xcheck_public_to_internal_dependency/ |
Category: Quality (Audit) | Verdict: ๐ข COMPATIBLE (bad practice)
Verdict and consumer impact¶
Single-release audit: one build's evidence checked against itself, no
baseline. abicheck's verdict is COMPATIBLE โ the ABI hasn't broken โ but
the audit flags an advisory finding: the public, header-declared
json_parse() calls straight into validate_utf8(), a helper declared
only in src/json_internal.cc, a private implementation file with no
public declaration anywhere. Nothing about json_parse()'s own signature
says this โ the dependency is only visible by walking the L5 source
graph's DECL_CALLS_DECL edge from the public declaration to the internal
one. If validate_utf8()'s behavior changes next release, json_parse()'s
contract silently shifts with it, with no signal in a public-header diff
at all. This is the intra-version counterpart to case150's
exported_not_public/public_not_exported pair: that pair catches a
mismatch between what's exported and what's declared; this one catches
a mismatch inside a single public entry point โ its behavior depends
on an entity consumers cannot see, version, or reason about independently.
What this snapshot contains¶
snapshot.abi.json is a single, hand-built AbiSnapshot carrying the L5
source graph for one build:
| Source in the snapshot | What it records |
|---|---|
| Public-header AST (L2) | json_parse declared in the public header |
L5 source graph (build_source.source_graph, DECL_CALLS_DECL / SOURCE_DECLARES edges) |
json_parse's definition calls validate_utf8; validate_utf8 is declared only in src/json_internal.cc, a file the source graph records as non-public |
abicheck command¶
Expected abicheck finding¶
Coverage
crosscheck:public_to_internal_dependency present L5 reachability: 1 public declaration(s) depending on an internal entity
ABI-hygiene catalog (intra-version, advisory)
[warning] public_to_internal_dependency: 1
Verdict: COMPATIBLE (exit 0)
The underlying edge, read directly off the loaded snapshot via
run_crosschecks() (the same call the CLI's cross-check pass and
tests/test_g20_catalog.py both drive):
python3 - <<'EOF'
import json
from abicheck.serialization import load_snapshot
from abicheck.buildsource.crosscheck import run_crosschecks
snap = load_snapshot("snapshot.abi.json")
res = run_crosschecks(snap)
for c in res.findings:
print(c.kind.value, c.symbol, "->", c.new_value)
EOF
Minimum evidence¶
min_evidence: L5 โ the binary (L0/L1) and the public-header AST (L2) each
see json_parse as a normal public function with no signal that it
depends on anything internal; only the L5 source graph's call edge from
json_parse's declaration to validate_utf8's internal-only declaration
exposes the dependency. A structural-only graph (no semantic/call-edge
pass) has no call edges to walk either โ this check needs the deeper S4/S5
semantic source pass, not just a source tree being present.
Why abicheck catches it¶
public_to_internal_dependency walks the L5 source graph's
DECL_CALLS_DECL edges starting from every public declaration, and flags
any edge that reaches a declaration whose own SOURCE_DECLARES provenance
is a non-public file. Neither the binary nor the header AST carries a
notion of "which internal function does this public function call" โ only
the source graph's call edges do, supplied by the source_index provider.
When the internal declaration's own file is among the revision's changed
paths (CrosscheckConfig.changed_paths), the same finding is reported at
higher confidence โ "this call reaches a file that changed this revision"
is a stronger signal than "this call reaches something internal" โ and
abicheck scan --since wires the changed-path set through automatically.
Why this matters for a real release¶
A consumer reading json_parse()'s public declaration has no way to know
its behavior depends on validate_utf8() โ that dependency is invisible
in the header, the binary's symbol table, and any ordinary v1/v2 diff of
the public surface, because validate_utf8 was never public API to begin
with. If a later release changes validate_utf8's behavior (tightens
validation, changes error handling, etc.), json_parse()'s observable
contract shifts too, but nothing in the public API surface signals a
change happened โ the diff between v1 and v2's headers would show nothing.
Catching the dependency now means the maintainer at least knows it exists
before deciding whether to stabilize, wrap, or sever it.
Safe redesign¶
- Make the dependency public: move
validate_utf8(or a stable wrapper around it) into a public header, so consumers can see and version it independently. - Or sever it: keep
json_parse's behavior independent of any internal helper's own evolution, so a private-file change can never silently alter a documented public contract.
Cross-tool comparison¶
public_to_internal_dependency is a cross-source check unique to
abicheck's audit mode โ it walks a source-level call graph from a public
declaration into private implementation files within the same build,
which isn't something abidiff/abi-compliance-checker do (they diff two
compiled ABI dumps against each other; neither reads source-level call
edges at all).
Source files¶
snapshot.abi.json
See also: Examples overview ยท All COMPATIBLE cases ยท Category: Quality (Compatible).