Skip to content

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

abicheck compare --no-baseline snapshot.abi.json

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
public_to_internal_dependency json_parse -> validate_utf8

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.