Evidence depth (--depth)¶
--depth is one dial on the two commands that collect evidence — compare
and dump — selecting how deep the collection goes (binary → headers →
build → source). abicheck compare OLD NEW is the way to run a
depth-pinned, source-aware comparison: it runs the always-on compiler-free
pattern pre-scan and every cross-source check
(CROSS_SOURCE_EVOLUTION_CHECKS) automatically on every invocation, and
takes the --depth/--since/--changed-path/--sources/--build-info
inputs directly. abicheck dump INPUT --depth … pins the same dial when you
are capturing a reusable snapshot instead of comparing.
scan has been retired
ADR-068
retired scan as a second analysis product — it duplicated compare
(with/without a stored baseline). Its removal was a hard removal with
no deprecation window (ADR-068 D8): abicheck scan now exits 64
with No such command, naming compare/compare --no-baseline in the
error. This page describes compare (and dump) only; a handful of
passages below still explain scan's own historical behavior for
context, clearly marked as such.
One capability on this page is still scan-only (verified live against
the current build, not read off --help), and it is an open migration item
in
plans/one-comparison-product.md
§3 rather than something compare silently covers:
--crosscheck KEY=errorpromotion. Opting a specific cross-source check into gating has nocomparespelling yet. The audit reports the same checks (see Single-build audit below), but cannot be told to gate on one.
The two entries that used to sit here are both gone, for opposite reasons.
The single-build audit closed on 2026-09-09/10: compare --no-baseline
reproduces the audit's findings, and the paragraph below says what it now
takes. Risk-driven auto depth was retired rather than mirrored --
see the next paragraph.
§3 row 28 is closed: compare now shares scan/dump's fail-loud
evidence-contract floor — a pinned --depth build/--depth source that
the collected evidence doesn't reach exits 7 (exit.reasons:
["evidence_contract_error"]) on compare too, verified live. See the
warning below for the one remaining difference between the three commands
(what each writes when it fires).
--budget no longer belongs on that list: compare gained its own
--budget wall-clock guard (ADR-068 §3 #19) — exit 5 on overflow applies
to both commands now. Neither does risk-driven auto depth, which was
retired rather than mirrored (ADR-068's second 2026-09-09 amendment,
ruling (b)): omitting --depth on scan now resolves to the fixed
headers rung, exactly the default omitting it on compare always gave.
scan --build-target was retired the same way, and dump --build-target
was later retired outright too (a hard usage error, exit 64) — put root
target(s) in .abicheck.yml's build.targets instead, the only route left
for either command. --crosscheck KEY=error promotion syntax is the one
item left with no compare equivalent.
The retired scan ARTIFACT [OPTIONS] command used to take the scanned
binary/snapshot as a positional argument (not a flag), with --against
OLD naming the previous dump/library/directory/package to compare against
and omitting it meaning a one-build audit. compare --no-baseline
CANDIDATE is that audit's replacement: it reproduces the audit's findings
in full, takes --depth/--sources/--build-info/--contract/
--dry-run, and was pinned against scan — at least every check scan
reported, counted per finding kind — on all eleven G20 audit fixtures by
tests/parity/test_no_baseline_audit_corpus_parity.py before scan was
deleted.
This topic in three pages — you are on Flags
Model — Evidence & Detectability:
the L0–L5 evidence layers, what each can and cannot see, and the
--depth dial that collects them. Read it first if the dial and the layers
look like they overlap.
Worked example — What Each Level Sees:
one tiny library walked up every level, with the actual data.
Flags — this page: the practical flag reference and the
worked examples below.
One dial selects how deep it goes — --depth, named by the evidence you get:
--depth binary|headers|build|source— the single knob (ADR-037 D5 / ADR-043 D2).binary= L0/L1 exported symbols + binary metadata;headers= +L2 header AST;build= +L3 build context;source= +L4 replay & the L5 graph. On bothcompareanddump, omitting--depthis not a fixedheadersdefault: each command infers the deepest rung its other inputs already justify. With no--sources/--build-infoat all, that inference bottoms out atheaders(nothing deeper to collect) — but supply--sources/--build-infowith no--depth, andcompareinferssource/buildfrom whichever was given, whiledumpgoes further still and always resolves tosource-targetinternally, collecting everything the supplied evidence reaches. Pin--depthexplicitly whenever you want a specific rung regardless of what other inputs are present, rather than relying on this inference. (Legacyscan's own unpinned default resolves to the fixedheadersrung; it used to score the risk of the changed paths and sometimes escalate, which ADR-068's second amendment retired.)- When
--depth sourceactually replays source, it always analyses something real, never a zero-TU no-op (ADR-043 D3): with a--since/--changed-pathseed it replays the changed TUs; without one it replays the whole current library target (what an older, now-removed--depth fullrung used to require explicitly) — so a replay that runs is never silently empty, just potentially more expensive unseeded. This guarantee is about the replay itself, not about whether the replay runs at all: with no usable--sources/--build-infoat all,--depth sourcenever reaches L4 replay in the first place — a pinned--depth sourcein that state fails loud (see the warning above), never silently exiting on a shallow verdict. - A single-build, no-baseline audit is
compare --no-baseline CANDIDATE(ADR-068 D2), or legacyscan CANDIDATEwith no--against. There is no separate--auditflag on either. A pinned depth is a contract on the--no-baselinepath too:compare --no-baseline CANDIDATE --depth buildwith no--sources/--build-infoexits7, same as the two-sided form (verified live, both spellings).
A pinned depth is a contract (fail-loud) — all three commands
Pinning a deep depth (--depth build|source) with no source input
(--sources/--build-info), or with input that doesn't actually reach
the requested depth, is a hard evidence-contract error on scan,
dump, and compare alike — not a silent shallow run. They differ only
in what each one writes when it fires:
scanexits7, its own dedicated evidence-contract exit code.dumpraisesDumpDepthNotSatisfiedError(cli_dump_helpers.check_requested_depth_satisfied) and exits1— no snapshot is written.compareexits7too (exit.reasons: ["evidence_contract_error"], folded through the sameExitDecisionprecedence rulescanuses) — but it still writes a full report, with the top-levelverdictleft at whatever the (unaffected) compatibility comparison produced. Checkexit, notverdict, to detect this oncompare— see Exit Codes for the same caveat on--abi3's
This floor applies to compare only when at least one side is a live
extraction (a binary/package operand, not a pre-existing
.abi.json/.abi.snapshot). Comparing two already-serialized
snapshots is exempted even when neither embeds L3/L4 evidence — a
pinned --depth source over compare v1.abi.json v1.abi.json reports
whatever the snapshots actually carry and exits 0/2/4 normally,
it does not exit 7. A clean result from a both-snapshot
compare is therefore not proof the pinned depth was actually
reached — only a run with at least one live side, or an explicit look
at each snapshot's own recorded depth, tells you that.
identical axis.
compare --dry-run does not preview this failure, though: pinning an
unsatisfiable depth under --dry-run still exits 0 and reports 0
TU(s) for the affected layers — see Estimate before you
spend below.
--mode/--source-method are gone
Earlier releases exposed a precise --source-method s0…s6 axis and
--mode pr|pr-deep|baseline|audit presets as deprecated aliases for
--depth. Both have since been removed outright (passing either is now
a plain usage error, exit 64) — use --depth. (--depth symbols was
likewise renamed to --depth binary, with no alias kept.)
Headers and includes — one side or both¶
-H/--header [old=|new=]PATH and -I/--include [old=|new=]PATH are
repeatable and side-aware on compare. A bare path applies to both
sides; prefix it with old=/new= to scope it to one, e.g.
--header old=old/include --header new=new/include. dump takes the same
flags without the prefix, since it has only one side.
# Same header layout for both sides
abicheck compare old/libfoo.abi.json new/libfoo.so -H include/
# The header layout moved between the old release and the new build
abicheck compare old/libfoo.so new/libfoo.so \
--header old=old/include --header new=new/include
What each depth reaches¶
--depth |
Reaches | Needs |
|---|---|---|
binary |
L0/L1 exported symbols + binary metadata + debug-info presence (no deep DWARF type walk, no L2 AST) + always-on pattern scan | just the artifact(s) |
headers |
+ L2 header AST (the public/internal boundary) | a public-header directory + a C/C++ frontend |
build |
+ L3 build context (flag/toolchain drift) | a compile DB / build dir |
source |
+ L4 source-ABI replay of changed TUs (seeded) or the whole library target (unseeded) + the L5 graph | sources and clang (+ a diff seed to scope it to just the changed TUs) |
What each depth does, in plain terms¶
binary— the always-available floor. Compares the two binaries' exported symbols, SONAME, and dependencies, and runs a compiler-free pattern pre-scan. Needs only the two artifacts — no source, no build, no compiler. It is the deliberate way to opt out of source analysis: a fast gate, or when no sources/compile DB are available. It skips the deep DWARF type walk and the L2 AST.headers— the header API surface. Adds the L2 header AST, which establishes the public/internal boundary — so an internal-symbol removal (compatible) is told apart from a public one (breaking). Needs a public-header directory (-H/--header) and a C/C++ frontend (castxmlorclang).build— build context. Reads a compile database to see the flags each translation unit was built with, so it can flag-fvisibility/-D/standard or toolchain drift between the two builds, plus (whenclang -Eis available) macro-value and include-graph divergence. Needs a compile DB / build dir.source— semantic replay, scope depends on whether you seed it. Re-parses translation units withclangand replays their ABI — the only depth that sees inline / template / macro / default-argument /constexprbody changes, and it folds the L5 reachability graph. With a diff seed (--since/--changed-path) it replays only the changed TUs (cheap, PR-sized); without one it replays the whole current library target (ADR-043 D3 — never a zero-TU no-op, but potentially as expensive as a full release-baseline replay). Needs a compile DB and the source checkout (--sources).
Benefits and cost at a glance¶
Each rung adds to the one below it — the benefit column is what that rung newly catches, the cost/implication column is what it asks of you in return.
--depth |
What it newly catches (benefit) | Cost & implication | Pin it when |
|---|---|---|---|
binary |
removed/changed exports, SONAME, dependency & version changes, no-DWARF vtable/RTTI size shifts | cheapest, flat with project size; no source-only API changes, and every exported symbol is treated as ABI (public/internal churn not separated) | you only have the two binaries, or want a fast pre-check |
headers |
the public/internal boundary → separates real API breaks from internal churn; signature / type-layout / enum / noexcept changes |
still cheap; needs public headers and a C/C++ frontend on PATH, else it falls back to binary-strict scope and over-reports |
you have the public headers — this is the floor for a trustworthy verdict |
build |
build-flag / toolchain / -std / visibility drift; macro-value & include-graph divergence |
cheap (~0.3–0.5s more); needs a compile DB / build dir — without one L3 is not_collected (reported, not a pass) |
the two builds may differ in flags, standard, or visibility |
source |
inline / template / macro / default-argument / constexpr body changes, plus the L5 reachability graph that localizes and scopes findings |
the one cost cliff (L4) — scales with C++ template depth; needs --sources + clang + a --since seed to stay cheap (unseeded, it replays every TU — the same cost as an amortized whole-library replay) |
a per-PR gate that must catch source-body changes or wants per-symbol impact; unseeded, the same rung also serves as the whole-library replay for producing an amortized release baseline |
The one rule that ties it together: the binary diff (binary/headers) sets
the pass/fail gate; build/source mostly localize and explain and
add their own source-/API-level findings — they rarely flip the verdict. So spend
on L4 (source) for humans reviewing a PR or a release, and stay in the
cheap tier for a fast CI gate.
Example-catalog status¶
The per---depth eval-target count, correct-verdict coverage, and FP/FN
counts are a volatile, machine-checkable fact with one owner: Tool
Comparison's "Current scan-quality
snapshot"
(the "Scan-depth matrix" row). Don't re-add a specific target count or
per-depth percentage table here — that page already records whether the
matrix has been re-run against the current catalog, and a second copy here
is exactly how this page's own numbers previously went stale (fixed
targets/percentages pinned to an older, smaller catalog, silently
presented as current). Qualitatively, the shape is stable across catalog
growth: binary is the fast artifact gate that intentionally misses
header/source-only breaks; headers is the best low-cost CI gate once
public headers are available, since it can see the public/private
boundary; build adds build-context corroboration on top; source has
the highest recall, since source-smoke proofs cover consumer-only API
hazards the artifact tiers can't see. (The measured matrix at the fact
owner above found zero extra false positives at headers/build depth —
a real, but catalog- and run-specific, result, not a guarantee this
qualitative description promises on its own.) source here is the
diff-seeded rung; Evidence &
Detectability
explains why an unseeded whole-library replay (the former full rung) is
treated as reaching the same verdict signal at higher cost. Bundle-component
results are structural diagnostics only in that matrix; only the dedicated
bundle lane scores the single canonical case-level verdict and proves
findings such as dangling intra-bundle imports and provider drift.
What input each depth needs, and how to get it¶
Every depth needs a specific input; without it the matching coverage row is
not_collected (the scan never silently pretends it ran). Pick the row that
matches your goal, then supply the input named in column 3.
| Goal (use case) | --depth |
Input you must provide | How to obtain it | If the input is missing |
|---|---|---|---|---|
| Binary-only ABI gate (removed/changed exports; no-DWARF vtable/RTTI size) | binary |
two .so (or .abi.json) |
release artifacts / conda / .deb |
always available (L0/L1) |
| Header-aware API surface + internal-vs-public scoping + cross-source checks | headers |
a public-header file or directory + a C/C++ frontend | -H include/ or -H include/foo.h on compare/dump (both establish the boundary identically; legacy scan instead takes --public-header-dir DIRECTORY, directory-only); castxml or clang on PATH |
with no -H at all, there is no public-header set → provenance/cross-checks stay dormant |
| Build-flag / toolchain / visibility drift (+ macro/include divergence) | build |
an L3 compile database | cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=ON (configure-only), meson setup, bazel aquery --output=jsonproto, or bear -- make; pass via --build-info |
L3 not_collected; the scan advises the exact remedy |
| Semantic source-ABI replay of changed TUs (macro/default-arg/inline/template/constexpr body changes) + L5 graph | source |
L3 compile DB + source checkout + clang + generated headers present |
configure for the DB; codegen/partial build for generated headers; seed with --since/--changed-path |
without a seed, source replays the whole current library target instead of just the changed TUs (ADR-043 D3 — never a zero-TU no-op, but more expensive); missing generated headers → L4 partial |
| Full-library source replay (an amortized release baseline) | source (unseeded — no --since/--changed-path) |
as above, whole library | amortized baseline build | expensive — the one cost cliff is at L4 |
| Single-build hygiene lint (accidental exports, leaks, unversioned/RTTI) | any depth, no --against |
binary + public-header dir (+ optional L3/L4) | as above | header_build_context_mismatch needs L3; odr_type_variant needs L4 |
Obtaining a compile database without a full build¶
The L3+ depths need a compile_commands.json; a pristine checkout has none.
Generate one — none of these compiles the library, they only configure / query
the build graph:
# CMake: configure-only (source also needs --sources . and a diff seed --since)
cmake -S . -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
abicheck compare old/libfoo.abi.json new/libfoo.so -H include/ \
--build-info new=build --sources new=. --since origin/main --depth source
# Bazel: query the action graph (no build); --build-info sniffs the aquery
# jsonproto and routes it straight to the Bazel adapter (ADR-037 D5 — no pack step)
bazel aquery 'mnemonic("CppCompile", //...)' --output=jsonproto > aq.json
abicheck compare old/libonedal_core.abi.json new/libonedal_core.so -H include/ \
--build-info new=aq.json --depth build
A stored baseline needs matching evidence on both sides
Every example on this page that compares a stored .abi.json baseline
against a live NEW build and supplies build/source evidence for NEW
alone (the CMake and Bazel commands above, and the .abicheck.yml
build.query example below) only works when that stored baseline was
itself dumpped with matching --sources/--build-info at bake time.
A baseline with no embedded L3/L4 facts paired with a NEW side that has
them gives the two sides different extraction profiles, which can stop
the run with exit 16 NOT_COMPARABLE instead of performing the
advertised comparison. Either bake the same evidence into the baseline
at dump time, or pass matching --sources old=/--build-info old=
alongside the new= one shown.
--build-info auto-detects the format (ADR-037 D5)
--build-info sniffs its argument by content, so each kind "just works":
a compile_commands.json (CMake/Meson/bear), a Bazel
--output=jsonproto aquery or cquery dump, a build directory
(searched for compile_commands.json), or a collect pack. A Bazel
query result is routed to the Bazel adapter — not mis-read as a compile DB.
Generated headers
L4 replay re-parses each TU with clang. If a TU #includes a header that
is generated during the build (e.g. version.h, *.pb.h, TableGen
*.inc), a configure-only tree won't have it and that TU's replay is
reported partial — run the project's codegen step first.
Letting abicheck drive the build query¶
You usually don't pre-generate a compile DB at all — just pass --sources.
When a source-level depth needs build evidence and no compile DB exists,
abicheck detects the build system and runs the query
itself for CMake (cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=ON), Bazel
(bazel aquery), and Make (make -B -n -k -w) — no flag, no manual build step.
No --allow-build-query flag is needed for --sources-driven auto-querying —
that flag was always a no-op and has since been removed outright: asking for
a source-level scan is the request to collect build evidence.
Make is queried with a fixed dry-run command (make -B -n -k -w) and the transcript
is scraped as reduced-confidence L3 evidence. This lets Make/EPICS-style projects
work without a manual compile_commands.json; a real compile DB (for example
from bear -- make, then --build-info compile_commands.json) is still preferred
when available.
Only an abicheck-constructed command runs automatically. An arbitrary
build.query command runs only when it is operator-supplied — an explicit
--config (the project .abicheck.yml contract), which is the only thing that
can authorize it: there is no CLI flag for a query.
An auto-discovered .abicheck.yml sitting inside the --sources tree is never
trusted to execute its build.query (it may be attacker-controlled); its
non-executing settings are still honoured. Pre-generating and passing a
--build-info yourself remains supported as an advanced option.
abicheck compare old/libfoo.abi.json new/libfoo.so -H include/ --sources new=. \
--config .abicheck.yml --depth source
Compile context for header parsing (L2)¶
The L2 header AST is what establishes the public/internal boundary — which
declarations are API, so the cross-source checks and public-surface scoping can
tell an internal symbol removal (compatible) from a public one (breaking). To
build it, the frontend must parse your public headers the way your compiler does:
it needs the include roots they #include, the C++ standard they assume, and any
-D feature macros that gate declarations. When that context is missing the
header parse fails, the run falls back to a binary-strict scope, and internal
removals get reported as BREAKING.
On compare and dump the compile context is a .abicheck.yml property,
not a flag — the toolchain a project's headers parse under is a stable
property of the project, not a per-run decision
(ADR-068
D5). The only per-run compile-context input either command takes on the CLI
is -I/--include DIR (an include root, repeatable). Everything else lives
in the compile: block:
compile: key |
Purpose |
|---|---|
frontend |
which frontend parses the headers — auto/castxml/clang/hybrid (env ABICHECK_AST_FRONTEND); hybrid runs castxml and clang together |
include_dirs |
include roots your headers need |
std |
the C++ standard the headers assume, e.g. c++20 |
defines |
-D feature macros that gate declarations |
options |
extra compiler flags, verbatim |
lang |
force c or c++ header parsing |
compiler |
a cross-compiler / cross-toolchain prefix |
sysroot |
an alternate system root |
nostdinc |
do not search system includes (and disable the auto-probe below) |
frontend_context, ast_frontend_fallback, allow_unsupported_castxml |
frontend-selection escape hatches |
Legacy scan still exposes the same axis as CLI flags
(--ast-frontend, --compiler-option, --compiler/--compiler-prefix,
--sysroot, --nostdinc, --lang); those spellings retire with the
command. The full key reference is
Config Keys; the compile: block's
own semantics are in Config File.
Where each setting belongs (CLI vs config)¶
Three layers resolve the context, highest precedence first:
.abicheck.ymlcompile:block — your project's stable contract, reviewed in PRs (see below). Put the frontend, include roots,std, anddefineshere so every CI run is reproducible without re-typing them.- Compile-DB-derived flags — planned: per-TU
-I/-std/-Dtaken from a--build-info. Today the compile DB feeds L3–L5 only. - Auto-detected system includes — the default floor (below).
# .abicheck.yml
compile:
frontend: auto # auto | castxml | clang | hybrid
std: c++20
include_dirs: [include, third_party/include]
defines: [FOO_ENABLE_FEATURE=1]
# sysroot: /opt/sysroot
# nostdinc: false
Auto-detection of system includes (on by default)¶
castxml finds the host C++ standard library for free, because it runs your real
compiler to discover its built-in include paths. The clang frontend did not —
so on a minimal container, a non-standard prefix, or a Conda-clang setup it could
not find <cstddef> and the parse failed. The clang backend now probes the
host GNU compiler (g++ -E -v) for its system include dirs and injects them, so
a bare compare … -H include/ finds libstdc++ without extra configuration.
Disable it with compile.nostdinc: true, an explicit compile.sysroot, or
ABICHECK_AUTO_SYSTEM_INCLUDES=0.
Auto-detection is partial — know its limits
- It recovers system headers (libstdc++/libc), not your project's own
include roots or
-Dfeature macros. Umbrella headers still need-I/thecompile:block for their own include root. - A wrong
-stdchanges the ABI surface (concepts,char8_t,noexcept-in-type, inline-namespace versioning) — parse at the standard the library was built with or L2 shows phantom add/remove churn. - Wrong/missing
-Ddefines change which declarations are visible — macro-gated internals (e.g.mylib::detail::*) or the libstdc++ dual ABI (_GLIBCXX_USE_CXX11_ABI) — and produce exactly the "scope divergence" false BREAKINGs this feature exists to remove. - Auto-detection reads the host toolchain → it is wrong for
cross-compiles (set
compile.compiler/compile.sysroot) and makes results host-dependent (pin context in config for reproducible CI).
Worked examples¶
Each example shows the command, what depth it pins, and what to read in the output. Every run ends with a coverage block — always read it before trusting the verdict (see Reading the coverage block).
PR gate (the default) — diff-seeded source¶
The common CI case: gate a PR by comparing the just-built library against the
baseline from main, scoping the expensive L4 replay to the files the PR
touched. The --since seed is what keeps this cheaper than an unseeded,
whole-library source compare — without it, source replays every TU.
abicheck compare artifacts/libfoo-main.abi.json build/libfoo.so \
-H include/ \
--sources new=. --since origin/main --depth source
- Depth: pinned explicitly here (
--depth source). Omitting--depthnever picks a rung by risk on either command any more, but with--sources new=.already given (as above)comparewould still infersource-targetfrom that input rather thanheaders; the pin exists for reproducibility, not because omitting it would fall back toheadershere. Onscan, omitting--depthdoes now meanheaders— it infers nothing from--sources, and no longer escalates from a diff seed. - Exit code (legacy scheme):
0compatible,2source/API break,4ABI break.--budgetoverflow (exit5) applies to bothcompareandscan— see Exit Codes. --depth sourcefolds the L5 reachability edges scoped to the changed TUs for cross-symbol impact in the report. The whole-library reachability graph is an internal level (GRAPH, D6) with no user-facing--depthrung.
Single-build audit — no baseline¶
abicheck compare --no-baseline CANDIDATE (ADR-068 D2) runs the
intra-version cross-source hygiene checks against one build — no
previous version required. With just the binary and headers it catches
accidental exports, private-header leaks, and unversioned symbols:
The findings land under findings[] — changes[] stays empty and verdict
stays null, because an audit reports no addition, removal, or
compatibility verdict (ADR-068 D2). Legacy abicheck scan CANDIDATE (no
--against) still runs the same checks; the audit is pinned to report at
least as many findings of every kind across all eleven G20 audit fixtures
below by tests/parity/test_no_baseline_audit_corpus_parity.py.
--sources/--build-info/--depth work on the --no-baseline path too,
so the L3/L4-dependent checks further down this section no longer need
scan. By default a hygiene finding never gates CI; migrating a
scan-based gating job needs exactly one addition —
--severity-preset default (or strict) — to keep gating on a
BREAKING/API_BREAK-classified finding (exit 3, the orthogonal
audit-gate axis, never 2/4); a non-gating job needs no change. See
Scenario S5 for the full
CLI account.
Worked example cases for each audit finding:
case143 (exported_not_public),
case144 (private_header_leak),
case145 (unversioned_exported_symbol),
case146 (rtti_for_internal_type).
case150 shows the
bidirectional exported_not_public ↔ public_not_exported pair, and
case151 shows confidence growing
with the number of corroborating sources (the provider-agreement matrix).
Some audit checks need more evidence than the artifact tiers provide:
header_build_context_mismatch compares the headers' parse context against the
real build flags, so it only fires when you also pass an L3 build input
(--build-info or --sources) — without one it is reported as a
skipped coverage row, not a pass:
This reports the full ADR-035 cross-source / single-release finding set
(CROSS_SOURCE_EVOLUTION_CHECKS) rather than a two-version diff. The
flagship cross-source cases —
case148
(header_build_context_mismatch, L2 macros ↔ L3 flags) and
case149 (odr_type_variant, L4
layout ↔ layout) — are findings that are invisible or ambiguous to any single
source and resolve only by crosschecking two.
Cheap gate — no compiler, no sources¶
When you only have the two binaries (or want a fast pre-check), pin a cheap
depth. --depth build adds build-flag/toolchain drift, but only when you also
give it a build input to read — a compile DB or build dir via
--build-info (or a --sources tree); without one, L3 is
reported not_collected and no drift is checked. --depth binary stays on the
exported-symbol surface (L0) plus cheap debug-info presence and the always-on
pattern scan — it skips the deep DWARF type walk, so no compiler, headers, or
sources are needed. (--depth headers is the next rung up: it adds the L2 header
AST, which needs a header directory via -H/--header and a C/C++ frontend on
PATH.)
# build-flag drift only, flat ~0.3–0.5s regardless of project size
# (the compile DB is what supplies L3 — without it the comparison is artifact-only)
abicheck compare old/libfoo.abi.json new/libfoo.so \
--build-info new=build/compile_commands.json --depth build
# exported symbols + always-on lexical scan only (no DWARF walk, no L2 AST,
# no L3/L4/L5; no compiler needed)
abicheck compare old/libfoo.abi.json new/libfoo.so --depth binary
Estimate before you spend — --dry-run¶
L4 cost scales with C++ template depth, so on a heavy library project the per-TU
replay cost first. --dry-run resolves and validates the invocation (depth,
scope, tool availability) and prints the projected per-layer cost for this
project without comparing anything or writing output. On scan, exits 0 for a
resolvable preview; an invalid invocation or an unsatisfiable requested depth
still exits nonzero, the same as the real run would (scan's evidence-contract
floor, see the warning above). compare --dry-run does not preview its own
real run's floor (verified live, still true even now that compare's
real-run floor is closed — see the warning above): compare --dry-run --depth
source with no build/source evidence resolvable still exits 0 and simply
reports 0 TU(s) for the L3/L4/L5 rows, where the equivalent real run would
now exit 7. Don't trust a clean --dry-run as proof the real run will
succeed on a pinned deep depth.
compare --no-baseline CANDIDATE --sources . --depth source --dry-run
previews an audit-only run, and unlike two-sided compare --dry-run it
does preview its own real run's floor: against a live candidate, a
pinned --depth build/--depth source with no --sources/--build-info
resolvable blocks the preview rather than reporting a clean plan (see
Scenario S5). Legacy
scan --sources . --depth source --dry-run still works too.
Release baseline — unseeded source¶
The reusable target that PR comparisons compare against is a
dump-produced snapshot. Pass --sources to embed all of the L3/L4/L5
facts so the later PR compare carries them:
# Produce the reusable baseline snapshot once per release
# (dump uses -H/--header, same as compare):
abicheck dump build/libfoo.so -H include/ \
--sources . --version 1.0 -o artifacts/libfoo-1.0.abi.json
# PR compares then run against it:
abicheck compare artifacts/libfoo-1.0.abi.json build/libfoo.so -H include/ \
--sources new=. --since origin/main --depth source
To get a whole-library comparison report of a release (replays every TU,
folds the full graph) for human review — as opposed to the reusable baseline
above — run compare --depth source without a --since/--changed-path
seed (which resolves to the whole current library target, ADR-043 D3 — what
a now-removed --depth full rung used to require explicitly) and send its
report to -o:
abicheck compare artifacts/libfoo-1.0.abi.json build/libfoo.so -H include/ \
--sources new=. --depth source -o json=artifacts/libfoo-1.0-report.json
Omitting --depth — auto, and why it is not risk-driven any more¶
Omitting --depth on scan resolves auto to the fixed headers rung —
the same default compare has always used. Through 2026-09-09 it read a
risk score over the changed paths and escalated on its own; ADR-068's
second amendment retired that (ruling (b)), along with the --risk-rules
profile that configured it.
This can hide findings a previous run reported. A seeded scan whose changed paths scored high used to escalate to
build/sourceby itself, so an unpinned run could report build- or source-only findings. It no longer does. If ascanstep relied on that escalation, pin--depth source(orbuild) explicitly — that was always the advice for a reproducible CI depth, and it is now the only way to ask for those layers. This is the documented breaking change the amendment accepted: a depth that is fixed and legible beats one that silently varied with the diff.
Omitting --depth is likewise never a risk-based choice on compare: with
--sources/--build-info given it infers source/build from them (see
above); only with neither
does it bottom out at headers.
The --since/--changed-path seed still matters, on both commands, for a
different axis: it scopes a --depth source replay to the changed TUs
instead of the whole current library target (ADR-043 D3).
Reading the coverage block¶
--depth requests a level but L is evidence, so a run can request a deep
level and only reach a shallow one (clang missing, no sources, a parse error).
On compare with no --depth pinned, this is never reported as
"failed" — the run states the depth it actually reached and, for each
disabled check, the input or tool to add. (This best-effort behavior does
not apply once --depth build|source is pinned and the evidence can't
reach it: scan and compare both fail loud there — exit 7 — and dump
raises DumpDepthNotSatisfiedError and exits 1 with no snapshot written;
see the warning above. compare is the one exception that still prints a
coverage block alongside its exit 7, since it writes a report either way.)
Checks enabled for this scan (and why others are not):
[on] Symbol presence & linkage … — from the binary's dynamic symbol table
[on] Build-flag & toolchain drift … — from build-system data
[off] Macros, default args, inline/template/constexpr bodies — no sources/clang:
source-only API changes are not detected
An [off] line is the precise input to add (here: install clang and pass
--sources). See
Build Info & Sources § Evidence coverage
for the full coverage and capability report.
case147 is the legibility anchor:
the same input scanned at --depth headers (pattern + AST), then deeper, with
the coverage block showing exactly what each depth proved and what it could not.
Cost guide (rules of thumb)¶
Measured on two UXL libraries (full data: validation/):
| Tier | Depths | Relative cost |
|---|---|---|
| Cheap | binary, headers, build |
One price — dominated by the binary dump + lexical scan, not the source layer. |
| Expensive | source |
clang per-TU AST replay (L4). |
- The cliff is at L4 (
build→source), and its height tracks C++ complexity. L4 cost scales with template/STL instantiation depth, not.so/TU count — a heavy-C++ library can be ~7× slower atsourcethanbuild, while a plain-C library is barely affected (~1.3×). - Choose a cheap depth by coverage, not cost.
binary(symbols + pattern only);headersadds the L2 API surface;buildadds L3 build context. sourceis only cheap when you give it a diff seed. Without--since <ref>or--changed-path <file>,sourcereplays the whole current library target instead of just the changed TUs (ADR-043 D3 — never a zero-TU no-op, but the most expensive shape, same cost as an amortized release baseline). With a real PR diff,sourcescopes L4 to the touched TUs and can be an order of magnitude faster for the identical verdict. Always pass--since/--changed-pathin PR CI.- The verdict usually does not change with depth — the binary diff sets the
gate; L3–L5 add localization/explanation. For a pass/fail gate, the cheap
tier is enough; spend on L4 (
source) when you want source-body semantics or per-PR localization for humans.
See Comparison Performance for the measured numbers.