Build Evidence Setup¶
This page is the operational reference for setting up build/source evidence
collection — the Clang-plugin build/wiring/traps, the .abicheck.yml
project-contract block, out-of-band packs, a full worked CMake example, and
external CLI extractors. For the abicheck-cc wrapper — the portable,
supported producer, including build-system wiring and extractor selection —
see Producing source facts,
which is also the place to start when deciding which producer to use. For
the concept and the authority rule,
see Build Info & Sources.
See also. For how this setup fits into a project's overall CI integration (which targets/profiles it feeds, how it composes with
check-target), see S8/S9: Source Facts From the Build Itself and Which Scenario Am I?.
Producing a pack — the Clang plugin (zero extra parse)¶
The Clang plugin (contrib/abicheck-clang-plugin/) emits the same
source_facts schema straight from the AST Clang already built during the real
compile, so — unlike the wrapper's companion parse — it adds no second
front-end pass. It registers as an AddAfterMainAction, so it runs after
codegen and can never change or fail the object file (the authority rule,
enforced not just claimed). Reach for it on large/template-heavy builds where
the wrapper's second-parse cost is measurable and you own the toolchain
image — because the plugin .so links the loading clang's libraries, it is
ABI-locked to that clang's LLVM major (see the version-coupling box below).
Step 1 — build the plugin, once, against your clang¶
# Debian/Ubuntu needs the matching libclang-XX-dev + llvm-XX-dev packages,
# not only clang-XX. CMAKE_PREFIX_PATH pins WHICH LLVM the plugin links.
cmake -S contrib/abicheck-clang-plugin -B build \
-DCMAKE_PREFIX_PATH="$(llvm-config --cmakedir)/.."
cmake --build build # -> build/libabicheck-facts.so
Validated across LLVM/Clang 16, 17, and 18 (ADR-038 C.6 differential
conformance gate). The .so you build here must be loaded by a clang of the
same major.
The plugin is ABI-locked to one LLVM major
A plugin built against LLVM N loaded into a clang of a different major
fails to dlopen — clang aborts the compile with an error like
unable to load plugin '.../libabicheck-facts.so': undefined symbol: _ZN4llvm....
There is no auto-fallback. If your build image can ship more than one
clang, build (or fetch) one plugin .so per major and select it by the
compiler's version. Detect the major with
clang --version / llvm-config --version and keep the .so path keyed
on it (e.g. plugins/clang-18/libabicheck-facts.so). This coupling is the
single reason to prefer the portable abicheck-cc wrapper or
compile_commands.json replay unless the second-parse cost is a real
problem.
Step 2 — connect it to your build (not one hand-edited compile)¶
The plugin is turned on by two things on the compile line: -fplugin=<path>
to load it, and -Xclang -plugin-arg-abicheck-facts -Xclang <key=value> to
configure it. Use the -Xclang form — the -fplugin-arg- shorthand
mis-parses the hyphenated plugin name and silently hands the argument to a
plugin called abicheck.
Rather than hand-editing every compile, define the flags once and inject
them into your build system's compile flags. public-roots= is strongly
recommended — it is the plugin's equivalent of ABICHECK_CC_HEADERS, the
public-header boundary that decides which declarations are ABI-relevant.
# Build the flag string once (absolute out= — see the parallel-build note).
ABICHECK_PLUGIN_FLAGS="\
-fplugin=$PWD/build/libabicheck-facts.so \
-Xclang -plugin-arg-abicheck-facts -Xclang out=$PWD/abicheck_inputs \
-Xclang -plugin-arg-abicheck-facts -Xclang public-roots=$PWD/include"
Then wire it into whichever build system you use.
CMake — append the flags to your normal build:
# Separate arguments (not one quoted string) so CMake passes each token through.
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug \
-DCMAKE_CXX_FLAGS="$ABICHECK_PLUGIN_FLAGS"
cmake --build build
Or, inside CMakeLists.txt, scope it to the library target only:
separate_arguments(ABICHECK_FLAGS UNIX_COMMAND "$ENV{ABICHECK_PLUGIN_FLAGS}")
target_compile_options(foo PRIVATE ${ABICHECK_FLAGS})
Make / autotools:
Bazel — in the cc_library/cc_binary rule (or a --per_file_copt):
cc_library(
name = "foo",
# ... srcs/hdrs ...
copts = [
"-fplugin=$(location //tools:libabicheck-facts.so)",
# Absolute out= (see the notes below) — a relative path scatters across
# per-target working dirs and is discarded by Bazel's sandbox.
"-Xclang", "-plugin-arg-abicheck-facts", "-Xclang", "out=/abs/path/to/abicheck_inputs",
"-Xclang", "-plugin-arg-abicheck-facts", "-Xclang", "public-roots=include",
],
)
Bazel sandboxes the compile — the pack is discarded by default
The plugin writes out= as a side effect Bazel does not declare as an
action output, so the sandbox throws it away. Run the fact-collecting build
with the compile step un-sandboxed (--strategy=CppCompile=local, or
--spawn_strategy=local) and point out= at one absolute path outside
the sandbox so the pack survives and every TU converges on it.
Compiler caches skip the compile — and the plugin with it
ccache/sccache key on preprocessed output plus arguments, not on
plugin side effects. A cache hit replays the cached object file
without running clang, so no facts are emitted for that TU — a
silent coverage hole, not an error. Run the fact-collecting build with the
cache disabled (CCACHE_DISABLE=1 / SCCACHE_RECACHE=1), or collect facts
in a dedicated pass separate from your cached incremental build.
distcc/icecc run the compile on a remote host, so source_facts/ lands
on the remote filesystem — collect on the driver, or fetch the pack back.
Make out= absolute for parallel / out-of-tree builds
out= resolves against each compile's working directory. A relative
out=abicheck_inputs in an out-of-tree build where the compiler runs in
per-target subdirectories scatters the pack into several
abicheck_inputs/ trees. Point out= at one absolute path (as in the
$PWD/abicheck_inputs above) so every TU converges on one pack.
Parallelism within one directory is safe — the plugin uses per-TU
race-free filenames and publishes manifest.json atomically. For a fresh
baseline, collect into an empty out= so an earlier build's stale
per-TU facts (e.g. for a since-deleted source) don't linger in the pack.
Step 3 — fold the emitted pack in¶
# One step, no re-parse — the pack is auto-detected from its manifest.json,
# no separate merge command.
abicheck dump libfoo.so -H include/ --build-info ./abicheck_inputs/ \
-o libfoo.baseline.json
Treat warnings about zero public declarations or 0/N matched exported
symbols as a pack-quality problem, not as a clean success: choose a compile unit
that includes the public API for the library target, and point public-roots= at
the physical header path printed by clang -H.
The plugin's full contract, coverage, and limitations live in
contrib/abicheck-clang-plugin/README.md. GCC (-fdump-lang-class) and MSVC
have documented wrapper fallbacks. In every case the output contract is
identical, so dump --build-info folds them in the same way, with no separate
merge step; the portable default remains compile_commands.json replay
(dump --sources).
Project-contract blocks (ADR-037 D4)¶
.abicheck.yml is also the home for the project's stable comparison contract —
the settings that are version-controlled and reviewed in a PR rather than typed
per run. compare auto-discovers the nearest config and merges CLI flags over
it (precedence CLI > config > built-in default). Loading is strict
(ADR-043): an unknown top-level or block key, a wrong-typed value, or a bad
enum is a hard error (exit 64), not a warning. A top-level version: records
the schema version.
version: 1
severity: # per-category overrides (CLI keeps only --severity-preset)
preset: strict # default | strict | info-only
abi_breaking: error # error | warning | info
potential_breaking: warning
quality_issues: info
addition: info
scope: # public-surface / FP tuning (stable project properties)
public: true
collapse_versioned_symbols: false
public_symbols: ["foo_init", "bar_init"] # exact match only — globs/wildcards are not supported
suppression: # suppression hygiene (a project rule, inherited by CI)
strict: true
require_justification: true
source:
method: s4 # legacy S-axis escape hatch (deprecated; prefer the --depth dial)
sources:
graph: summary # summary | full — L5 source-graph detail (the key is sources.graph)
exit_code_scheme: auto # auto | legacy | severity (ADR-037 D12)
The matching CLI flags (e.g. --severity-abi-breaking, --strict-suppressions,
--collapse-versioned-symbols) stay as hidden per-run overrides — functional
but off the visible surface. The L2/L4 frontend is one knob, --ast-frontend
(auto/castxml/clang; env ABICHECK_AST_FRONTEND), shared across header-AST
parsing and source-ABI replay (ADR-037 D8). hybrid (G28 Phase 3) is header-AST
only for now — it runs castxml and clang together and merges them, but has no
L4 source-ABI-replay path yet.
Advanced: out-of-band packs, and what collect/graph left behind¶
History note: the standalone
collectcommand (which wrote a raw, on-disk pack directory) and thegraph explain/graph comparecommands were both removed outright in the ADR-043 CLI reset — neither has a direct CLI replacement.collect's capability lives on asdump --sources/--build-info's inline collection (below) plus a few library-only functions for advanced producers;graph explain/graph compare's structural diff/localization is Python-API only (see "The L5 graph's own diff/localize" below) — only its derived findings still surface automatically through an ordinarycompare.
A pack directory (from the abicheck-cc wrapper, the Clang plugin, or a
hand-written producer) can be embedded (dump --build-info <pack> /
--sources <pack> auto-detect a pack by its manifest.json) or supplied
out-of-band per side at compare time instead:
# (Advanced) Override or supply facts out-of-band per side instead of embedding:
abicheck compare old.abi.json new.abi.json \
--build-info old=old.bs/ --build-info new=new.bs/
The one-step inline flow replaces collect + a separate embed¶
dump --sources <tree> is the collection step now — it resolves the
compile database (inferring and running the CMake/Bazel/Make query itself
when none is given), runs the L4 replay, and folds the L5 graph, embedding
everything straight into the .abi.json in the same invocation that dumps
the binary+headers:
There is no longer a separate pack-then-embed step, and no --source-abi-scope/
--source-abi-extractor/--source-graph flags to pick — --depth source
always runs L4 replay + folds the L5 graph (with the same three automatic
edge kinds dump --sources has always added: approximate call edges
DECL_CALLS_DECL, type/field-dependency edges, and compile-unit include
edges — each degrading gracefully without clang++ rather than aborting).
--depth build stops at L3 (structural graph only, no L4 replay) for a
cheaper run when you don't need the source-ABI findings.
What collect's advanced flags have no CLI replacement for¶
A few capabilities collect exposed as flags never got a dump/compare
equivalent — ADR-043 D4 judged them below the five-command bar — but the
underlying library functions were not deleted, only their Click wiring:
collect flag (removed) |
Library function to call instead |
|---|---|
--from cmake/ninja/bazel/make (explicit build-system adapter) |
abicheck.buildsource.adapters.{cmake_file_api,ninja,bazel,make} — --sources already infers one of these automatically for the common case |
--read-compiler-record |
abicheck.buildsource.compiler_record (ELF .GCC.command.line / DWARF DW_AT_producer, advisory) |
--source-abi-cache (persistent per-TU replay cache) |
abicheck.buildsource.source_replay.SourceAbiCache / the ABICHECK_L4_CACHE_DIR env var still works with dump --sources |
--extractor-manifest (external CLI extractors) |
abicheck.buildsource.extractor_manifest.load_extractor_manifest() / run_external_extractor() — see "External CLI extractors" below |
--collection-mode {permissive,strict,audit} |
No survivor — a failed producer step degrades coverage silently when scripted directly; call the library function inline in your own producer script if you need one of these behaviors |
--kythe-entries/--codeql-results |
abicheck.buildsource.graph_backends.ingest_kythe_entries() / ingest_codeql_call_results() |
See abicheck/buildsource/CLAUDE.md for the full module map if you need to
script one of these directly.
The L5 graph's own diff/localize (graph explain/graph compare) is Python-API only¶
from abicheck.buildsource.source_graph import diff_source_graph, localize_symbol
# Structural delta between two source-graph summaries (nodes/edges added/removed)
delta = diff_source_graph(old_graph, new_graph)
# What produced and reaches a symbol: exporting target, source declaration(s),
# declaring public header(s), ABI-relevant build option(s), static callees.
explanation = localize_symbol(graph, "_ZN3foo3barEv")
Both load a SourceGraphSummary — from a pack directory's
graph/source_graph_summary.json, or from BuildSourcePack.load(path).source_graph.
Per the authority rule, this only explains and prioritizes impact; it never
decides or suppresses an artifact-proven ABI break on its own. What did
carry forward automatically into compare is the graph's derived
findings (SOURCE_TO_BINARY_MAPPING_CHANGED, PUBLIC_REACHABILITY_CHANGED,
INCLUDE_GRAPH_PUBLIC_HEADER_DRIFT, etc.) — those are ordinary ChangeKinds
a --sources/--build-info comparison reports like any other finding, with
no separate command needed to see them.
Worked example: a CMake library, end to end¶
Two releases of libfoo, each built with CMake. The goal is a full
L0+L1+L2+L3+L4 compare so a build-flag change or a source-only API change
is caught alongside the binary diff.
# --- For EACH release (old and new), at build time ---
# 1. Build with -g and export the compile database (one extra CMake flag).
cmake -S libfoo-1.0 -B build-old -DCMAKE_BUILD_TYPE=Debug \
-DCMAKE_EXPORT_COMPILE_COMMANDS=ON
cmake --build build-old
# 2. Snapshot the built library WITH headers, the build context, AND the
# source tree — one step folds L0-L2 (binary+headers), L3 (build-old's
# compile DB), and L4/L5 (replay + graph from libfoo-1.0) into one
# self-contained snapshot. --build-info points at the compile DB since
# build-old lives outside the libfoo-1.0 --sources tree.
abicheck dump build-old/libfoo.so -H libfoo-1.0/include \
--sources libfoo-1.0 --build-info build-old/compile_commands.json \
--depth source --version 1.0 -o libfoo-1.0.abi.json
# (repeat steps 1-2 for the new release → libfoo-2.0.abi.json)
# --- At compare time (CI), just the two snapshots — facts are already embedded ---
abicheck compare libfoo-1.0.abi.json libfoo-2.0.abi.json
The compare prints the coverage table and capability report
first, so you can confirm every layer landed before trusting the verdict — if a
row says not_collected or [off], that is exactly the input or tool to add.
Because dump --sources/--build-info embeds the normalized facts into the
.abi.json, a normal compare old.json new.json carries the L3/L4/L5
findings with no out-of-band directories to manage or keep in sync —
pass --build-info old=/new= only when you deliberately want to override a
side's facts at compare time instead of what's already embedded.
External CLI extractors & the security model (ADR-032)¶
A build system abicheck does not natively support can be integrated through an
external CLI extractor — a separate program registered by a YAML manifest,
talked to over a subprocess boundary with declared inputs, outputs, and actions.
No untrusted Python is ever imported into the abicheck process. There is no
longer a CLI command to invoke this (the removed collect --extractor-manifest
was the only wiring); call it from Python instead:
# my-extractor.yaml
name: abicheck-cmake-extractor
version: "1.0"
capabilities: { compile_db: true, target_graph: true }
allowed_actions: [inspect, query_build_system]
commands:
collect: ["abicheck-cmake-extractor", "collect", "--output", "{raw_dir}"]
normalize: ["abicheck-cmake-extractor", "normalize", "--raw", "{raw_dir}", "--out", "{normalized_dir}"]
outputs:
normalized:
- { kind: build_evidence, path: build/build_evidence.json }
from pathlib import Path
from abicheck.buildsource.extractor import CollectionContext, DEFAULT_ALLOWED_ACTIONS
from abicheck.buildsource.extractor_manifest import (
load_extractor_manifest, run_external_extractor,
)
manifest = load_extractor_manifest(Path("my-extractor.yaml"))
context = CollectionContext(
binary_paths=[Path("libfoo.so")],
header_roots=[Path("include")],
compile_db=Path("build/compile_commands.json"),
# allowed_actions defaults to DEFAULT_ALLOWED_ACTIONS (inspect only); add
# CollectionAction.QUERY_BUILD_SYSTEM etc. explicitly to permit more.
)
result, ledger_record = run_external_extractor(manifest, context, Path("libfoo.evidence"))
The security model has three pillars, enforced the same way whether the manifest is driven by this library call or (historically) the deleted CLI:
- Trusted-by-operator, never auto-discovered. A manifest runs only when your own code loads it explicitly by path. abicheck never scans a filesystem path, the working tree, or any plugin directory looking for one.
- Declared actions are a ceiling, not a grant.
inspect(read existing files) is the only action allowed by default;query_build_system,run_compiler,run_build,wrap_build, andnetworkare denied by default (network always) unless the caller explicitly opts in. A manifest'sallowed_actionsare intersected with what the caller permits, so a manifest can never escalate beyond what you turned on — and an extractor that needs an action you did not enable is skipped with a diagnostic, never run. - No shell, sanitized environment. Commands are an argv list (never a
shell string) run with
shell=Falseand a minimal environment, so a third-party tool never receives your full environment (which may hold tokens). Note the action model gates invocation — abicheck refuses to launch an extractor that needs a disallowed action — but it does not sandbox a process once launched;networkbeing denied means no extractor that declares it is run, not a kernel-level block. This is why manifests are trusted-by-operator: register only extractors you vet.
Every external run records a full reproducibility ledger row in the pack manifest (ADR-032 D10): the redacted command, its content hash, declared capabilities, start/finish timestamps, status, and diagnostics.