Skip to content

Case 164: Preprocessor-Conditional Field (Build-Context False Positive)

Field Value
Verdict NO_CHANGE
Category No Change
Platforms Linux, macOS, Windows
Flags
Detected ChangeKinds
Source files examples/case164_preproc_conditional_field/

Category: Build-Context Reconciliation (ADR-039) | Verdict: ✅ NO_CHANGE (only with --reconcile-build-context; a context-free header read misreports BREAKING)

Verdict and consumer impact

This case demonstrates ADR-039 (build-context reconciliation): a false positive that the binary and headers depths cannot avoid and that only build evidence resolves.

Ground truth is NO_CHANGE: both v1 and v2 of libconfig.so.1 are built with -DCONFIG_KEEP_LEGACY defined, so the shipped Config struct is identical ({version, legacy}, 8 bytes) on both sides — no consumer is affected, nothing needs recompiling. But a header-only read (no build evidence) sees legacy guarded behind #ifdef CONFIG_KEEP_LEGACY and, parsing it with the guard undefined, prunes the field from Config's member list — reporting a type_field_removed false positive. Trusting that context-free read alone would fail CI on a release that changed nothing.

Old/new diff

v1 public header v2 public header
struct Config { int version; int legacy; }; struct Config { int version; #ifdef CONFIG_KEEP_LEGACY int legacy; #endif };

Both releases are compiled with CONFIG_KEEP_LEGACY defined — the real shipped layout never moves.

abicheck command

# Context-free header read (no build evidence) -- the false positive:
abicheck compare v1.abi.json v2.abi.json --scope-public-headers

# With build-context reconciliation (ADR-039) -- the phantom clears:
abicheck compare v1.abi.json v2.abi.json --scope-public-headers \
  --reconcile-build-context

Expected abicheck finding

Without build context:
Verdict: BREAKING (exit 4)

- type_field_removed: Field removed: Config::legacy -- config.h:10
  > Old code accesses a field that no longer exists at the expected offset;
    reads garbage or writes out of bounds.
  Affected symbols: cfg_make

With --reconcile-build-context:
Verdict: NO_CHANGE (exit 0)

_No ABI changes detected._
(--show-filtered discloses: "Reconciled as context-free header-parse
artifacts (1 finding): type_field_removed: Config [config.h:10]")

Minimum evidence

min_evidence: L3 — resolving this correctly needs the build's active -D set (build_context_defines), which only build evidence (a compile database or equivalent) carries. A header-only (L2) read parses #ifdef guards context-free and cannot know which macros the real build defines, so it is exactly the depth that produces the phantom here.

Why abicheck catches it

The default header-AST parse evaluates #ifdef CONFIG_KEEP_LEGACY with the macro undefined, so legacy is absent from the parsed field list even though every shipped build has it. --reconcile-build-context cross references the case's conditional_fields registry (populated from the compile database's active -D set during a build-aware dump) against each struct's guarded members: since CONFIG_KEEP_LEGACY is in build_context_defines on both sides, the guarded field is proven present in both real builds and the phantom removal is reclassified as a context-free parsing artifact rather than a genuine break (ADR-028 D3: a reconciled finding is disclosed under --show-filtered, never silently dropped, and an unconditional removal — or one guarded on an undefined macro — is never reconciled away).

Real-world deployment scenario

This is what a CI job diffing two release header snapshots sees if it scopes to public headers before the build system's -D flags are known — e.g. a header-only ABI-diff step that runs ahead of, or independent from, the actual compile. Feeding that same job the project's compile_commands.json (L3) via --reconcile-build-context is what turns "the header text changed" into "the shipped struct changed," and here the two answers disagree.

Safe redesign

Avoid #ifdef-guarded fields in a public struct definition unless the guard is genuinely universal across every shipped configuration; if a field really is conditional, either drop the guard (always ship it) or move it behind an opaque accessor so header-only tooling never has to resolve preprocessor state to know the real layout. Where a guard must stay, publish the active -D set (a compile database) alongside the headers so ABI-diff tooling — abicheck's --reconcile-build-context here — has the evidence to resolve it instead of guessing.

Cross-tool comparison

abidiff/abi-compliance-checker diff DWARF-carrying compiled binaries, not header-only snapshot pairs with a build-context overlay; this case's fixtures (v1.abi.json/v2.abi.json plus the conditional_fields/ build_context_defines metadata) have no equivalent input for either tool, so there is nothing to reproduce with them here.


Source files

  • v1.abi.json
  • v2.abi.json

See also: Examples overview · All NO_CHANGE cases · Category: No Change.