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.jsonv2.abi.json
See also: Examples overview · All NO_CHANGE cases · Category: No Change.