Case 181: Public API Reaches an Internal Declaration¶
| Field | Value |
|---|---|
| Verdict | 🟢 COMPATIBLE |
| Category | Quality (Compatible) |
| Classification | Rule · audit |
| Platforms | Linux |
| Flags | Bad practice |
Detected ChangeKinds |
public_to_internal_dependency |
| Source files | catalog/cases/case181_xcheck_public_to_internal_dependency/ |
| Rule family | xcheck-public-to-internal-dependency |
| Subject | Export/declaration mismatches, Public API depends on an internal declaration |
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 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¶
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: header
## Candidate-side findings
| Finding | Symbol | Severity | State | Detail |
| --- | --- | --- | --- | --- |
| `public_to_internal_dependency` | `json_parse` | potential_breaking | present in this build | Public API 'json_parse' depends on internal entity 'validate_utf8' (declared in a private header / source file, not the public surface) via a DECL_CALLS_DECL edge. Consumers cannot see it, so a change to it is an undeclared behavioral risk. Make the dependency public or sever it. |
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.cross_source_checks 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.
CrosscheckConfig.changed_paths can raise the same finding to higher
confidence when the internal declaration's own file is among the
revision's changed paths — "this call reaches a file that changed this
revision" is a stronger signal than "this call reaches something
internal". The retired scan --since wired a changed-path set through to
this automatically; compare's own migrated cross-source-evolution path
(workflows.cross_source_evolution.compute_cross_source_evolution, the
checker.compare() call site this check now runs through) does not
currently pass --since/--changed-path through to
CrosscheckConfig.changed_paths at all — verified live in
workflows/cross_source_evolution.py's own _run_one_side, whose
CrosscheckConfig(...) construction never sets changed_paths — so this
case is always reported at the lower "reaches something internal"
confidence today, regardless of --since. This also means the
higher-confidence variant is not reachable from a two-sided
abicheck compare OLD NEW --since <rev> either, not only from the
--no-baseline audit shown above (--since/--changed-path are rejected
outright under --no-baseline, exit 64, since a single-build audit has no
revision range to narrow). See docs/contribute/known-gaps.md.
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: Compatibility Catalog · All COMPATIBLE cases · Category: Quality (Compatible) · Rule: Public declaration depends on an internal one · Subject: Export/declaration mismatches · Subject: Public API depends on an internal declaration.