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.jsonold.json
See also: Examples overview ยท All COMPATIBLE_WITH_RISK cases ยท Category: Risk.