Skip to content

Case 190: Public Inline Function References Internal Constant

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

Category: Risk (Source Graph) | Verdict: ๐ŸŸก COMPATIBLE_WITH_RISK

Verdict and consumer impact

demo::compute() is a public, inline function โ€” its compiled body is inline int f() { return DETAIL_CONSTANT + 1; }

baked into every consumer's own translation unit. In v2 that body starts reading an internal (non-public-header) constant, detail::kInternalLimit, that it never touched in v1. Nothing about compute()'s own declaration changes, so no artifact-level diff (symbols, DWARF, header AST) sees anything at all. The risk is real but silent: if detail::kInternalLimit's value ever changes, an already-built consumer keeps the old value baked into its own binary until it recompiles, while a freshly built one picks up the new value โ€” a version-skew-dependent divergence with no compile error, link error, or crash to flag it.

Old/new diff

v1 (conceptual) v2 (conceptual)
inline int demo::compute() { return 0; } inline int demo::compute() { return detail::kInternalLimit; }

This case ships hand-built SourceGraphSummary fixtures (old.json / new.json) rather than compilable v1/v2 sources โ€” see scripts/gen_l3l4l5_examples.py. The table above is the C++ shape the fixture's DECL_REFERENCES_DECL edge represents; the committed JSON is the actual old/new diff.

abicheck command

There is no compiled binary or header pair here to point abicheck compare at โ€” the fixture is a SourceGraphSummary (an L5 evidence-model object), the same kind of artifact dump --sources/--build-info would embed inside a real snapshot. The reproducible command runs abicheck's own source-graph diff function directly against the two committed fixtures:

python3 -c "
import json
from abicheck.buildsource.source_graph import SourceGraphSummary, diff_source_graph_findings
old = SourceGraphSummary.from_dict(json.load(open('old.json')))
new = SourceGraphSummary.from_dict(json.load(open('new.json')))
for c in diff_source_graph_findings(old, new):
    print(c.kind.value, c.symbol, c.old_value, '->', c.new_value)
"

Expected abicheck finding

public_api_internal_dependency_added demo::compute() no internal dependency -> reaches 1 internal decl(s)/type(s)

Verdict: COMPATIBLE_WITH_RISK โ€” a public_api_internal_dependency_added risk finding, not a hard break; per ADR-028 D3 this class of finding never decides a shipped-ABI break on its own, it flags elevated risk and localizes the cause for review.

Minimum evidence

min_evidence: L5 โ€” the derived source graph is the only layer that carries DECL_REFERENCES_DECL edges from an inline function's body to whatever it reads. No lower tier can see it: the exported-symbol table (L0/L1) has nothing to diff (an inline function has no symbol of its own), and the header AST (L2) records compute()'s unchanged declaration but never parses function bodies.

Why abicheck catches it

type_graph.py's Clang-AST pass walks inline/template/constexpr function bodies and records every declaration they reference as a DECL_REFERENCES_DECL edge in the source graph. Comparing the old and new graphs shows demo::compute() gained an edge to detail::kInternalLimit that wasn't there before; public_api_internal_dependency_added fires because the target is a private-header declaration reached from a public-header entry point.

Runtime failure demonstration

There's no app.c here because this isn't an artifact-provable break โ€” it's a source-only risk that shows up only when a project builds with --sources/--build-info evidence (the L5 graph). The real-world analogue is a CI job that runs abicheck compare --sources <old-tree> --sources <new-tree> (or diffs two committed source-graph dumps) across releases: it would flag that a header-only inline function newly depends on an internal implementation detail, so a reviewer can catch the coupling before it ships rather than after a customer reports mismatched behavior across a partially upgraded fleet.

Safe redesign

Either promote detail::kInternalLimit to a documented public constant, or keep demo::compute()'s inline body independent of internals whose evolution consumers cannot track โ€” e.g. move the logic out-of-line behind a stable exported symbol so a value change becomes a proper, artifact-visible ABI event instead of a silent per-TU baked-in constant.

Real-world example: header-only libraries (Eigen, fmt-as-header-only mode) that inline dispatch logic referencing internal tuning constants are exactly this shape โ€” a constant tweak between releases silently changes behavior only for consumers who happen to recompile.

Cross-tool comparison

abidiff/abi-compliance-checker operate on compiled binaries and debug info; neither has an equivalent to abicheck's L5 source-graph pass, so neither tool can see this finding at all โ€” there is nothing to diff (no symbol, no DWARF entry, no header AST delta). This finding is unique to abicheck's build-source evidence layers (ADR-028 through ADR-033).


Source files

  • new.json
  • old.json

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