Skip to content

Case 162: Exported Symbol's Declaring File Moved

Field Value
Verdict ๐ŸŸก COMPATIBLE_WITH_RISK
Category Risk
Platforms Linux
Flags โ€”
Detected ChangeKinds exported_symbol_source_owner_changed
Source files examples/case162_symbol_source_owner_changed/

Category: Build/Source Evidence (L5) | Verdict: ๐ŸŸก COMPATIBLE_WITH_RISK

This case ships a hand-built pair of evidence-model fixtures (old.json + new.json โ€” SourceGraphSummary dumps) instead of compilable v1/v2 sources, so the corpus is also validated compiler-free by tests/test_l3l4l5_examples.py (which calls diff_source_graph_findings on the same two files directly). See scripts/gen_l3l4l5_examples.py for how they were generated.

Verdict and consumer impact

The exported symbol demo::init is declared by include/demo/legacy.h in v1 and by include/demo/init.h in v2, per the L5 source graph's SOURCE_DECLARES edge โ€” while the symbol's name and signature are completely unchanged. Nothing breaks for a consumer that includes whichever header currently declares demo::init and links against either release: the ABI is stable. The risk is narrower โ€” a consumer that included include/demo/legacy.h specifically (rather than a stable umbrella header) now gets a different, possibly missing, declaration, and the refactor can introduce include-path drift, unexpected inlining changes, or an ODR risk if the old header still declares the symbol too.

Old/new diff

v1 source graph v2 source graph
demo::init() declared by include/demo/legacy.h (SOURCE_DECLARES edge); maps to exported symbol _ZN4demo4initEv demo::init() declared by include/demo/init.h โ€” same exported symbol _ZN4demo4initEv, same name/signature

abicheck command

# old.json / new.json here are this case's committed L5 source-graph
# fixtures (SourceGraphSummary dumps, as produced by build_source_graph over
# a real source checkout) -- not AbiSnapshot files, so they're supplied to
# abicheck as an out-of-band --build-info pack rather than as compare's
# positional inputs.
mkdir -p pack_old/graph pack_new/graph
echo '{"build_source_pack_version": 1}' > pack_old/manifest.json
echo '{"build_source_pack_version": 1}' > pack_new/manifest.json
cp old.json pack_old/graph/source_graph_summary.json
cp new.json pack_new/graph/source_graph_summary.json

# Nothing changed at the binary/header level between v1 and v2, so a pair of
# otherwise-empty snapshots stands in for the (unchanged) artifact side.
python3 -c "
from abicheck.model import AbiSnapshot
from abicheck.serialization import save_snapshot
save_snapshot(AbiSnapshot(library='libdemo.so', version='1.0'), 'empty_old.json')
save_snapshot(AbiSnapshot(library='libdemo.so', version='2.0'), 'empty_new.json')
"

abicheck compare empty_old.json empty_new.json \
  --build-info old=pack_old --build-info new=pack_new

Expected abicheck finding

Verdict: COMPATIBLE_WITH_RISK (exit 0)

- exported_symbol_source_owner_changed: demo::init's declaring file
  changed (include/demo/legacy.h -> include/demo/init.h)
  > Name and signature are unchanged, so the artifact diff is quiet; the
    file owning the declaration moved -- review for include-path,
    inlining, or ODR effects.

Minimum evidence

min_evidence: L5 โ€” the symbol's name, mangled name, and signature are byte-for-byte identical between v1 and v2, so the exported-symbol table (L0), DWARF (L1), and the public declaration's own text (L2) all show no change at all. Only the L5 graph's SOURCE_DECLARES edge, which tracks which file declares a given source entity, records that the declaration's owning file moved โ€” a fact no artifact-level diff carries.

Why abicheck catches it

build_source_graph emits one SOURCE_DECLARES edge per declaration, pointing from the header node that declares it to the source-entity node, alongside the existing SOURCE_DECL_MAPS_TO_SYMBOL edge to the exported binary symbol. diff_source_graph_findings follows the same declaration's SOURCE_DECL_MAPS_TO_SYMBOL edge across both graphs and reports exported_symbol_source_owner_changed when the source side of its SOURCE_DECLARES edge differs while the mapped symbol stays the same.

Build/deployment scenario

This is what a source-graph audit over two release checkouts would flag during a header reorganization: demo::init's declaration moved from a legacy umbrella header to a new, more specific one. The exported symbol and its signature are untouched, so nothing fails to link or load in either release โ€” the risk is purely at the source level, for any consumer that included include/demo/legacy.h directly instead of a stable public entry point, and for maintainers who assume both headers still declare the symbol.

Safe redesign

Confirm the declaration move is intentional and that no other header still declares the same symbol (which would create an ODR/multiple-declaration risk), and update documentation or examples that reference the old include path. If the old header needs to keep working for one release, leave a forwarding #include "init.h" in legacy.h rather than deleting the declaration from it outright.

Real-world example: large header reorganizations โ€” splitting a monolithic umbrella header into per-feature headers โ€” routinely relocate declarations like this; libraries commonly leave a forwarding #include in the old location for one release specifically to avoid silently breaking direct includes.

Cross-tool comparison

Neither abidiff nor abi-compliance-checker have an operand here: both tools diff compiled binaries (plus optional DWARF/headers), and a declaration's owning file is โ€” by construction โ€” invisible at that layer once the symbol's name and signature are unchanged; DWARF only records a DW_AT_decl_file for the compilation unit's own view, not a cross-release ownership diff. The whole reason this needs L5 source-graph evidence is that no binary or header-AST-only diff tracks which header a stable symbol comes from across releases.


Source files

  • new.json
  • old.json

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