Case 197: Declaration Reconciled as Identity-Reconciled (Header Unchanged)¶
| Field | Value |
|---|---|
| Verdict | 🟡 COMPATIBLE_WITH_RISK |
| Category | Risk |
| Classification | Scenario — Capability / evidence demonstration |
| Platforms | Linux |
| Flags | Bad practice |
Detected ChangeKinds |
declaration_identity_reconciled_unresolved, public_api_internal_dependency_added |
| Source files | catalog/cases/case197_header_graph_identity_reconciled/ |
| Related rules | internal-declaration-identity-reconciled, public-api-gains-internal-dependency |
| Subject | Public API depends on an internal declaration |
Category: Risk (Source Graph / Reconciliation) | Verdict: 🟡 COMPATIBLE_WITH_RISK
Verdict and consumer impact¶
This is the sibling scenario to
case196, isolating the
other outcome ADR-048's graph reconciliation can classify a genuine
node-identity change into. A private, never-exported internal helper
demo::detail::helper keeps its exact qualified name and its declaring
header (include/demo/detail.h, unchanged on both sides of this fixture) —
only its parameter type changes from int to long, which moves its
Itanium mangled name (_ZN4demo6detail6helperEi →
_ZN4demo6detail6helperEl) and therefore the L5 graph node's own id. Because
the qualified name is unchanged, the alias tier still pairs the two nodes —
but because the declaring file did not change this time,
graph_reconcile._classify_outcome falls to its residual branch (neither
renamed nor moved individually fired) and classifies the outcome as
declaration_identity_reconciled rather than declaration_moved.
This is the identical fixture design as case196 — same private+inline
helper, same public inline caller, same new-side-only dependency edge, same
real source_graph.mark_source_edges_extractor_coverage() certification —
with exactly one variable held constant: the declaring header path. Case196
varies both the signature and the header path to reach declaration_moved;
this case varies only the signature, holding the header path fixed, to reach
declaration_identity_reconciled — the two cases together cover both
non-"pure-rename" outcomes _classify_outcome can produce for a
signature-perturbed node.
A public inline function, demo::process, genuinely starts calling the
helper — only on the new side of this fixture — for the same reason
case196's does: an inline caller's body is emitted into every consumer TU
that includes the header, so its call is consumer-visible under any
reasonable reachability notion, sidestepping the question of which
reachability predicate a given detector happens to use.
demo::detail::helper is deliberately inline too, for the same
linkability reason case196 documents: since demo::process's body
(including its call to detail::helper) is emitted into every consumer's
own translation unit, an ordinary out-of-line, non-exported
detail::helper would leave that consumer-emitted call as an unresolved
external symbol reference — a real link failure, not a purely internal,
risk-only change. Making detail::helper inline too means its own body is
also emitted into that same consumer TU, so the call resolves locally
with no external symbol needed at all.
Both fixture surfaces carry a coverage.fact_set/fact_family_states
rollup naming the one source_edges producer whose coverage genuinely
matches a full, unfiltered call/type-graph replay
(source_graph._FULL_WALK_SOURCE_EDGES_PRODUCER), certified through the
real source_graph.mark_source_edges_extractor_coverage() helper rather
than a hand-forced extractor_passes["call_graph"] = True. With both sides
genuinely certified this way, the old side's zero calls is a confirmed
zero and the new side's one call is a genuinely new dependency.
source_graph_findings._internal_dependency_findings (the
public_api_internal_dependency_added producer, demonstrated in
case160) therefore
correctly fires here, alongside graph_reconcile's own
declaration_identity_reconciled on the helper.
The helper is deliberately private, for the same reason case196's is: a
public function's mangled-name-moving signature change is itself a real,
independent BREAKING change, which would contradict ground_truth.json's
invariant that one canonical verdict applies to the scenario a case
describes. With the identity-perturbing edit confined to a
private_header-visibility declaration never present in the exported
symbol table, a real end-to-end compare() of this exact scenario has
nothing BREAKING to contradict — COMPATIBLE_WITH_RISK is the genuinely
correct canonical answer, carried entirely by the two RISK-tier L5 findings
this fixture reproduces.
Old/new diff¶
| v1 (conceptual) | v2 (conceptual) |
|---|---|
// include/demo/api.h#include "demo/detail.h"inline void process() { /* does not call helper yet */ }// include/demo/detail.hnamespace detail { inline void helper(int) { /* ... */ } } |
// include/demo/api.h#include "demo/detail.h"inline void process() { detail::helper(1L); }// include/demo/detail.hnamespace detail { inline void helper(long) { /* ... */ } } |
This case ships a hand-built pair of evidence-model fixtures (old.json /
new.json, SourceGraphSummary objects) instead of compiled v1/v2
sources. Like case196, these fixtures are produced by running real
SourceEntity/BuildEvidence facts through the actual production fold
(source_graph.build_source_graph) — the same function dump --sources/
--build-info calls — so the two node ids are genuinely distinct for the
reason a real extractor would make them distinct, not because the generator
invented an artificial id. See
scripts/gen_l3l4l5_examples.py.
abicheck command¶
There is no compiled binary or header pair to point abicheck compare at —
the fixture is a raw SourceGraphSummary, the same evidence object
dump --sources/--build-info would embed inside a real snapshot. The
reproducible command runs abicheck's source-graph diff function directly:
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::process no internal dependency -> reaches 1 internal decl(s)/type(s)
declaration_identity_reconciled demo::detail::helper demo::detail::helper -> demo::detail::helper
Verdict: COMPATIBLE_WITH_RISK — both are pure L5-evidence risk annotations.
Because the identity-perturbing edit lands on a private_header-visibility
declaration, a real binary comparison of this exact scenario has no
BREAKING/API_BREAK finding to sit alongside them; reconciliation only
explains/localizes, it never suppresses or replaces another finding
(ADR-028 D3).
Minimum evidence¶
min_evidence: L5 — recognizing the two nodes as "the same declaration,
resignatured" instead of an unrelated remove+add pair requires the derived
source graph's canonical-identity/alias matching (G31 Phase B, ADR-048):
both nodes share the qualified name demo::detail::helper. No lower
evidence tier carries graph node identity at all.
Why abicheck catches it¶
abicheck.buildsource.graph_reconcile runs canonical-identity and
graph-reconciliation matching between the old and new source graphs. Here,
the qualified name is unchanged on both sides, so the alias tier resolves
the match directly. graph_reconcile._classify_outcome then compares each
side's declaring file, recovered from the real SOURCE_DECLARES edge
build_source_graph creates from each function's SourceLocation: unlike
case196, the file is also unchanged here — neither the renamed
condition (name changed) nor the moved condition (file changed) holds
individually, so the outcome falls to the residual branch and is classified
declaration_identity_reconciled. The demo::process →
demo::detail::helper DECL_CALLS_DECL edge exists only in the new graph,
and both graphs mark their call-graph pass as confirmed (so the old side's
absence is a proven zero, not missing evidence): this satisfies
graph_reconcile._public_reachable_ids's gate (a purely-internal,
never-publicly-reached signature change would otherwise be suppressed
rather than reported as RISK) and is itself the genuinely-new dependency
source_graph_findings._internal_dependency_findings correctly credits as
newly reached.
Runtime failure demonstration¶
There's no app.c here and no crash to demonstrate — demo::detail::helper
is never exported as a symbol, and (being inline itself) resolves entirely
within each consumer's own translation unit, so no consumer binary ever
references it as an external symbol and no real binary-level break exists
to demonstrate. The real-world scenario is a CI job that runs abicheck with
--sources/--build-info evidence across two releases and posts the
findings to a PR: without reconciliation, a reviewer sees an unexplained
"demo::detail::helper removed, demo::detail::helper added" pair (same
name, same header, different mangled symbol — easy to misread as an
unrelated pair of internal declarations, or even as an extraction glitch
since the header didn't move) and has to manually confirm whether it
matters; with it, the PR comment notes the pair is the same internal
declaration, resignatured in place, alongside a separate note that
demo::process picked up a new internal dependency in the same release —
legibility for an entirely internal refactor, not a break to chase down.
Safe redesign¶
No fix needed — demo::detail::helper is a private implementation detail.
demo::process being inline does mean its body — including the call to
helper — is compiled into each consumer's own translation unit (that
consumer-compiled dependency is exactly what the RISK finding above
reports); what stays invisible to every consumer is helper itself: it is
never independently exported or nameable, so no consumer links against it
as a symbol or could reference it directly even if it wanted to. If this
helper needs to become part of the public API, promote it deliberately
(move its declaration to a public header, export it) rather than relying
on it staying accidentally reachable only through demo::process's own
translation unit.
Real-world example: resignaturing a private helper in place (no header reorganization involved) is routine during ordinary internal refactors; without a reconciliation step, a generated changelog or ABI report would otherwise list the pair as two unrelated, unexplained internal symbol changes — indistinguishable from a genuine removal-plus-unrelated-addition.
Cross-tool comparison¶
abidiff/abi-compliance-checker operate on compiled binaries and debug
info; neither has an equivalent to abicheck's source-graph canonical-identity
and reconciliation machinery, and neither would see anything here at all —
demo::detail::helper is entirely internal and outside either tool's
ABI-surface scope regardless. This finding and its reconciliation are unique
to abicheck's L5 build-source evidence layer (ADR-048). Contrast with
case196 (the sibling
case where the declaring header also changes, reached instead via the
declaration_moved outcome), and with
case194/
case195
(pure renames, no signature change, resolved via the weaker
structural-context tier or deliberately left unreconciled).
Source files¶
new.jsonold.json
See also: Compatibility Catalog · All COMPATIBLE_WITH_RISK cases · Category: Risk · Subject: Public API depends on an internal declaration.