Output Formats¶
abicheck supports multiple output formats for different use cases:
| Format | Flag | Best for |
|---|---|---|
| Markdown | --format markdown (default) |
Human review, PRs, terminals |
| JSON | --format json |
CI pipelines, machine processing |
| SARIF | --format sarif |
GitHub Code Scanning, SAST platforms |
| HTML | --format html |
Standalone reports, ABICC migration |
| JUnit XML | --format junit |
GitLab CI, Jenkins, Azure DevOps test dashboards |
All five formats support the report filtering options described below.
The ABICC-compatible XML output (via abicheck compat check) includes
redundancy annotations but does not support --show-only filtering.
In addition to report formats, abicheck can emit GitHub Actions workflow
command annotations (--annotate) that appear as inline comments on PR
diffs. See GitHub PR Annotations for details.
Redundancy filtering¶
When a root type change (e.g. struct size change) causes many derived changes
(e.g. 30 FUNC_PARAMS_CHANGED entries for functions using that struct),
abicheck automatically collapses the derived changes. The root type change is
annotated with:
caused_count— number of derived changes collapsedaffected_symbols— list of affected interface names
This keeps reports focused on root causes. Use --show-redundant to disable
filtering and see all changes.
How it appears in each format¶
Markdown: An info note at the bottom:
> ℹ️ 12 redundant change(s) hidden (derived from root type changes).
> Use `--show-redundant` to show all.
JSON: A top-level redundant_count field, and per-change caused_by_type
and caused_count annotations on root type changes.
SARIF: caused_by_type and caused_count in result properties;
redundant_count in run-level properties.
HTML: A highlighted banner showing the redundant count.
XML (ABICC compat): <redundant_changes> element in <problem_summary>,
<caused_by> and <caused_count> elements on individual problems. Both binary
and source sections include their own redundant counts.
JUnit XML: Redundant changes are filtered upstream before the formatter receives them, so derived changes do not appear as test cases. No JUnit-specific redundancy metadata is emitted.
Public-header surface scoping¶
Public-header surface scoping (ADR-024) restricts findings to the public ABI surface — the symbols exported and declared in the public headers you supplied, plus the types reachable from them. Changes that fall outside that surface (e.g. a layout change to an internal struct no public API references) are not dropped: they are moved to an audit ledger so the "why was this excluded" trail stays inspectable. Internal-type leaks are never filtered.
Scoping is on by default (ADR-024 Phase 5). When no public-header surface
can be resolved — e.g. comparing two stripped .so files with no header or
DWARF provenance — scoping is automatically a no-op and every finding is
reported, so the default never hides anything it cannot place. Pass
--no-scope-public-headers to force the unscoped report (every finding,
regardless of surface).
Use --show-filtered to print the ledger on the terminal.
Widening the surface (--public-symbol)¶
Some symbols you do guarantee as public can't be seen by header provenance —
hand-written asm stubs, .def exports, extern "C" shims, or symbols whose
MSVC mangling castxml can't match. The widening overlay (ADR-024 §D6) forces
such symbols back into the public surface so their changes are reported rather
than demoted:
# Force individual symbols (repeatable), à la abi-compliance-checker -symbols-list
abicheck compare old.so new.so --scope-public-headers \
--public-symbol my_asm_stub --public-symbol _ZN3foo3barEv
# Or from a file (one symbol per line; '#' comments and blank lines ignored)
abicheck compare old.so new.so --scope-public-headers \
--public-symbols-list public.syms
Matching is on the symbol as recorded on the finding (mangled or demangled),
plus the trailing :: segment of a qualified name. Widening only ever keeps a
finding — it can never hide a break — and only takes effect together with
--scope-public-headers. It is the counterpart to suppression, which narrows
the surface; the two remain separate, auditable inputs.
How it appears in each format¶
Each demoted finding carries a reason code explaining why it was excluded:
not-exported— the symbol is known but not in the public export set.non-public-type— the type is reachable from no public API root.private-header— the declaration originates in a project header outside the public-header set.system-header— the declaration originates in a toolchain/system header (/usr/include, MSVC, Xcode SDK, …).no-provenance— a type demoted by reachability while provenance was available for the snapshot but not for this type, so the demotion is reachability-based rather than provenance-confirmed (reduced confidence).
The private-header / system-header reasons are provenance-derived: they
only appear when the snapshots were produced with --public-header /
--public-header-dir (ADR-015). --public-header is supported for
ELF, PE (provenance from PDB LF_UDT_SRC_LINE), and Mach-O inputs. Without a
public-header set, every declaration's origin is unknown and only the
linkage/reachability reasons above are emitted.
Scope-resolution confidence¶
The ledger also carries a structured confidence in the surface resolution itself (ADR-024 §D5.3), distinct from the overall verdict confidence:
confidence:"high"(a clean header-scoped run) or"reduced".notes: structured codes explaining any reduction —mangling-fallback/header-backend-unavailable(header scoping was requested on a PE/Mach-O binary but fell back to the export table; recorded on the snapshot asscope_fallback), orno-provenance(the surface resolved without any declaration provenance).
Text: With --show-filtered, an audit block on stderr (the reason is shown
in parentheses):
Filtered as non-public ABI surface (1 finding, --scope-public-headers):
- type_size_changed: InternalCache (non-public-type)
JSON: A top-level surface_scope object (present only when scoping is
active):
"surface_scope": {
"enabled": true,
"confidence": "high",
"notes": [],
"out_of_surface_count": 1,
"out_of_surface_changes": [
{"kind": "type_size_changed", "symbol": "InternalCache",
"description": "Size changed: InternalCache (64 → 128 bits)",
"source_location": null, "reason": "non-public-type"}
]
}
SARIF: A surfaceScope object in run-level properties with
confidence, notes, outOfSurfaceCount, and outOfSurfaceChanges (same
per-finding fields, camelCased; reason included when known), present only
when scoping is active.
--show-only filter¶
Limit displayed changes by severity, element, or action (AND across dimensions, OR within each). Does not affect the verdict or exit codes.
Markdown / JSON / HTML: Changes are filtered before rendering. A note shows
how many changes matched: > Filtered by: --show-only ... (5 of 42 changes shown).
SARIF: The show_only parameter filters which results appear in the SARIF
output.
JUnit XML: The show_only parameter filters which test cases appear in the
output. Filtered-out changes are omitted entirely.
--stat mode¶
One-line summary for CI gates:
$ abicheck compare old.json new.json --stat
BREAKING: 3 breaking, 1 risk (42 total) [12 redundant hidden]
$ abicheck compare old.json new.json --stat --format json
{"library": "libfoo", "verdict": "BREAKING", "summary": {...}}
--report-mode leaf¶
Groups output by root type changes with affected interface lists, instead of listing every change individually. Available in Markdown and JSON formats.
--report-mode root-cause¶
Groups findings that share a root cause under one entry, instead of listing
every change individually — e.g. an internal helper's func_removed finding
and the internal_symbol_required_by_public_api overlay finding that names
it both land in the same group. Supported for --format json/markdown
(the default rendered text output), and sarif (as additive properties,
see below); junit still renders as full (no testsuite grouping
equivalent yet — JUnit's <testcase> model already groups by symbol, not
by finding). This is a
first slice reusing the existing Change.caused_by_type field (see
ADR-052);
a future slice (G29 Phase 6) will additionally correlate consumer-overlay
findings that don't share a caused_by_type today.
{
"root_causes": [
{
"root_cause_id": "ad544909f783ad0d",
"root": "ns::internal::helper",
"finding_count": 2,
"findings": ["... the two grouped Change entries ..."]
}
],
"root_cause_count": 1,
"changes": ["... the same findings, flat, for backward compatibility ..."]
}
The Markdown/text rendering groups the same way, one ### root heading per
group instead of --report-mode full's severity-bucketed sections:
## Root Causes (1)
### `ns::internal::helper` (2 findings)
- **func_removed**: helper removed
- **internal_symbol_required_by_public_api**: required
SARIF keeps its normal one-result-per-finding shape (so every existing
SARIF/code-scanning consumer keeps working unchanged) but adds
properties.rootCauseId/properties.rootCause to every result — group them
yourself by rootCauseId if you want the same buckets JSON/markdown show:
{
"ruleId": "internal_symbol_required_by_public_api",
"properties": {
"rootCauseId": "ad544909f783ad0d",
"rootCause": "ns::internal::helper"
}
}
--show-impact¶
Appends an impact summary table to the report, showing root changes and how many interfaces each affects. Available in Markdown and HTML formats.
A second output format from the same run (--secondary-format)¶
compare computes its comparison once; --secondary-format renders that
same result into a second format/file, instead of requiring a second
abicheck compare invocation to get a different format:
# A markdown report for humans, plus a JSON artifact for tooling —
# one comparison, two outputs.
abicheck compare old.json new.json \
--format markdown \
--secondary-format json --secondary-output report.json
--secondary-formatand--secondary-outputrequire each other — either alone is rejected (passing just--secondary-outputwould otherwise silently produce no secondary artifact at all).--secondary-outputmust point at a different file than--output/-o— otherwise the secondary render would silently overwrite the primary report.- The secondary render always emits the full, unfiltered report: it ignores
--show-only/--stat, which describe only the primary format's display. - Not supported for directory/package (release) comparisons — the release
fan-out doesn't produce a single
DiffResultto render twice. Compare the libraries individually to use it.
The bundled GitHub Action uses this to get JSON for its sticky PR comment without re-running the whole comparison a second time.
Analysis confidence and evidence tier¶
Every comparison reports how much evidence backed the verdict, so consumers can calibrate trust. Three related fields appear in the Markdown "Analysis Confidence" section and the JSON report:
| Field | Type | Meaning |
|---|---|---|
confidence |
high / medium / low |
Overall trust level (does the available evidence corroborate the verdict, and were any detectors disabled). |
evidence_tier |
elf_only / dwarf_aware / header_aware |
Canonical, ordered analysis depth. Key trust decisions off this scalar. |
evidence_tiers |
list of strings | Raw data sources that were available (elf, dwarf, dwarf_advanced, header, pe, macho). Retained for backward compatibility. |
The evidence_tier scalar collapses the raw sources into a single ordered label
(shallow → deep):
elf_only— symbol-table-only. Binary export tables (ELF/PE/Mach-O) are present, but there is no DWARF debug info and no header/AST surface. Only symbol add/remove and version changes are observable; struct layout, enum values, and type changes are not.dwarf_aware— DWARF (or equivalent debug info) is present, enabling struct layout, enum, and calling-convention analysis, but no header/AST surface is available to cross-check declared API intent.header_aware— a parsed header/AST surface (functions/types/enums) is present. The richest of the three artifact tiers: it can reason about declared-but-not-emitted API (default-argument values,const/constexprconstants,final, access, ref-qualifiers). It does not see macro contracts or inline/template body changes — castxml/clang's declaration AST doesn't model macros or bodies at all; that requires the separate L4 source-ABI-replay layer below.
These three values correspond to the artifact evidence layers L0–L2. The higher layers do not promote this scalar, and they differ in what they produce:
dump -p build/only bakes the build context into how the headers are parsed and recordsparsed_with_build_contexton the snapshot. On its own it adds no L3 findings and no evidence-coverage table — a plaincompare old.json new.jsonof two-p-dumped snapshots still reports only the L0–L2 artifact verdict.- Build/source build/source packs (L3/L4) are what add build-diff/source-diff
findings and the
layer_coveragetable, and only when you pass them at compare time via--build-info(or a deeper--depthover--sources). These findings follow the authority rule — L3/L4 never overrides an artifact-proven verdict.
See Evidence & Detectability for the full L0–L4 model.
{
"verdict": "BREAKING",
"confidence": "high",
"evidence_tier": "header_aware",
"evidence_tiers": ["elf", "dwarf", "header"]
}
Per-finding epistemic status (evidence_status)¶
The three fields above describe the comparison as a whole. Each individual
finding in changes[] (JSON) or SARIF results[].properties can also carry
an evidence_status (JSON) / evidenceStatus (SARIF) label — how that
specific finding was proven, distinct from its kind/severity (what it
is):
| Value | Set when | Means |
|---|---|---|
artifact_proven |
the finding's kind is intrinsically a BREAKING_KINDS member, and this comparison's evidence_tiers confirm a real binary was examined |
L0/L1/L2 artifact evidence confirms a shipped ABI break. |
unattributed |
the finding's kind is intrinsically a BREAKING_KINDS member, but this comparison's evidence_tiers show only "header" — no real binary (ELF/PE/Mach-O/DWARF) was ever examined |
The kind's own classification still stands, but this specific run cannot back it with an actual artifact — e.g. a Python-API caller comparing hand-built/loaded AbiSnapshot objects. Not a downgrade of the kind, only of what this run can claim to have proven. |
source_contract |
intrinsically API_BREAK_KINDS |
A source-level break that needs a recompile or a policy decision — not necessarily a shipped ABI break. |
contextual_risk |
intrinsically RISK_KINDS (COMPATIBLE_WITH_RISK under the default policy) |
Build/source/deployment context suggests risk without proving a break. |
consumer_proven |
(set explicitly, not derived from the finding's own classification) | Runtime/appcompat/plugin-check evidence demonstrated that a specific consumer actually depends on what changed — see Application Compatibility. |
not_checkable |
(the finding itself) | The finding is the missing-evidence signal (evidence_required_missing, ADR-033 D7), not a break — the coverage gap is explicit rather than a silent gap in the report. |
COMPATIBLE/NO_CHANGE findings (additions, clean comparisons) carry no
evidence_status — there is no epistemic strength to qualify.
evidence_status is a function of the finding's kind, refined by exactly
one comparison-level fact — unlike severity/the gate/exit code, it
follows no verdict-modulation mechanism at all: not the active --policy
(a named policy like plugin_abi folds every COMPATIBLE_WITH_RISK kind
into its breaking set for gating; sdk_vendor downgrades source-level
kinds), not a PolicyFile kind-set override, not a PolicyFile
evidence_policy ceiling (the build_context_drift/source_only_findings/
graph_risk_findings knobs, ADR-033 D7), and not a per-finding
effective_verdict (ADR-027 A4 pattern modulation, frozen-namespace
escalation). All of those change what fails the build, not what evidence
actually proved — and since more than one of them share the same
effective_verdict field, there is no reliable way to tell "a detector
individually re-examined this one finding" apart from "an operator's
evidence-tier ceiling swept a whole bucket," so none are trusted. The one
comparison-level signal that is trusted is the run's own
evidence_tiers — not a gating decision at all, but a positive record of
what was actually examined — which is what distinguishes artifact_proven
from unattributed above. severity answers "does this fail the build
under the active policy?"; evidence_status answers "what kind of evidence
backs this finding, full stop?" — the two fields can disagree, and that's
by design.
{
"kind": "func_removed",
"symbol": "_Z3foov",
"severity": "breaking",
"evidence_status": "artifact_proven"
}
Stable finding IDs and structured operation (finding_id, operation)¶
Each finding in changes[] also carries:
operation— a structured"added"/"removed"/"modified"classification, derived from the same kind-suffix rule--show-only'sadded/removed/changedtokens already use. Lets a consumer group or filter findings by operation without hand-maintaining its own list of_added/_removedkind-name suffixes.finding_id— a stable, deterministic fingerprint (a truncated SHA-256 hash ofkind/symbol/old_value/new_value/source_location/description) that identifies this finding independent of its position in thechanges[]array. Twocompareruns over the same underlying change produce the samefinding_id, so a consumer can correlate a finding across two report runs (e.g. tracking a waiver, or diffing which findings are new between two CI runs) without relying on array order or index, neither of which abicheck guarantees stays stable release to release.descriptionis included specifically to disambiguate two otherwise-identical findings on the same symbol (e.g. the same pointer-depth change reported on two different parameters of one function).finding_iddeliberately excludes policy-derived fields (severity,evidence_status) — the same underlying finding hashes identically regardless of the active--policy.
{
"kind": "func_removed",
"symbol": "_Z3foov",
"operation": "removed",
"finding_id": "3f2a9c8b1d4e5f60"
}
Recommended action per finding (recommended_action)¶
Each finding also carries a structured, machine-readable next step, derived
from the same effective verdict/category resolution severity/operation
already use — so it can never disagree with them for the same finding:
recommended_action |
When | Meaning |
|---|---|---|
recompile_and_relink_required |
verdict BREAKING |
Binary ABI break — existing compiled consumers must be recompiled and relinked against the new library. |
recompile_required |
verdict API_BREAK |
Source-level break only — existing compiled binaries keep working, but source recompiling against the new headers will fail. |
verify_deployment_compatibility |
verdict COMPATIBLE_WITH_RISK |
Binary-compatible, but may fail to load in some deployment environments — needs manual verification, not a recompile. |
review_recommended |
verdict COMPATIBLE, not an addition |
A quality issue (e.g. an STL type exposed by value, missing SONAME) — compatible, but worth a look. |
no_action_required |
verdict COMPATIBLE, an addition |
New public API surface — purely additive, nothing to do. |
{
"kind": "func_removed",
"symbol": "_Z3foov",
"severity": "breaking",
"recommended_action": "recompile_and_relink_required"
}
Reviewer guidance for additions (reviewer_action)¶
recommended_action: "no_action_required" is accurate for the old binary
consumer — nothing to recompile, nothing to relink — but collapses every
addition to the same value even though a reviewer approving new public API
surface almost always has something to check. Findings with
recommended_action: "no_action_required" also carry a reviewer_action
key with that finer-grained guidance; every other finding omits the key,
since recommended_action itself is already reviewer-actionable there.
reviewer_action |
When | Meaning |
|---|---|---|
review_exhaustive_switches |
kind enum_member_added |
Old binaries are unaffected, but a source consumer's exhaustive switch/sentinel-value pattern may silently miss the new value. |
document_stable_replacement |
kind experimental_graduated |
An unstable API just became part of the stable support contract — document the change, don't just ship it. |
confirm_public_api_intent |
every other addition | Confirm the new export was intentional (not an accidental symbol leak) and consider a release note. |
{
"kind": "enum_member_added",
"symbol": "Color::PURPLE",
"severity": "compatible",
"recommended_action": "no_action_required",
"reviewer_action": "review_exhaustive_switches"
}
Typed gate summary (severity.blocking, severity.blocking_categories)¶
When --severity-* configuration is active, the top-level severity object
gets two additional fields alongside the existing config/categories/
exit_code:
blocking—truewhen the severity-aware exit code is non-zero (equivalent toexit_code != 0, provided as a named boolean so a consumer doesn't have to know the exit-code convention).blocking_categories— the list of category names (abi_breaking,potential_breaking,quality_issues,addition) that both have findings and are configurederror— i.e. the categories actually responsible for the non-zero exit code, mirroring SARIF'sproperties.severityGateblock.
{
"severity": {
"config": {"abi_breaking": "error", "potential_breaking": "warning", "quality_issues": "warning", "addition": "error"},
"categories": {"addition": {"severity": "error", "count": 1}},
"exit_code": 1,
"blocking": true,
"blocking_categories": ["addition"]
}
}
Contract-evaluation report fields (contract_relevance, contract_coverage_failures)¶
Under --contract-evaluation, two field groups appear that aren't present
in a plain compare report — mental model and command reference:
Contract-Aware Compatibility,
Contract Evaluation.
Per finding — whether policy actually scored it, and why:
{
"kind": "func_removed",
"symbol": "detail::internal_helper",
"contract_relevance": "PROVEN_OUT_OF_CONTRACT",
"contract_reason_code": "terminal_authoritative_exclusion",
"contract_assurance": "complete",
"compatibility_evaluation_status": "NOT_EVALUATED",
"compatibility_decision": null,
"gate_contribution": 0
}
Read this as: the finding is real and detected — compatibility_decision:
null means policy never ran on it, not that it was judged compatible.
contract_relevance/contract_reason_code say why it was excluded from
scoring; gate_contribution: 0 confirms it moved neither the verdict nor
the exit code.
Run level — the separate question of whether there was enough evidence to make any contract decision at all:
An incomplete-evidence run looks like this instead. Compatibility itself has
no separate process exit — only the overall exit code does — so read this as:
the compatibility verdict stays NO_CHANGE (nothing evaluated found a
problem), while contract_coverage_exit_contribution: 1 independently
floors the overall process exit at 1 by default, because the two axes are
orthogonal:
{
"verdict": "NO_CHANGE",
"contract_coverage_failures": [
{
"provider": "export_table",
"side": "old",
"record_id": "export_table:old",
"reason": "provider_unavailable",
"status": "unavailable",
"completeness": "not_started",
"mode": "exports",
"suppressible": false
}
],
"contract_coverage_exit_contribution": 1
}
suppressible: false is structural, not a default someone forgot to flip —
a CoverageFailure has no kind/symbol/source_location for
--suppress to match against, so it cannot silence one even in principle.
JSON schema and stability guarantees¶
The compare --format json document is a stable, machine-readable contract.
It is described by a versioned JSON Schema (draft
2020-12) that ships inside the package at
abicheck/schemas/compare_report.schema.json and is importable:
from abicheck.schemas import (
REPORT_SCHEMA_VERSION, # the MAJOR.MINOR this build emits
COMPARE_REPORT_SCHEMA_PATH, # pathlib.Path to the .schema.json
load_compare_report_schema, # -> dict
)
Every JSON report carries a top-level report_schema_version field
(MAJOR.MINOR) so consumers can detect the contract version they are reading.
Two version numbers, two contracts.
report_schema_version(above) versions the comparison report emitted bycompare. It is distinct from theschema_versioninteger inside a snapshot (.abi.json) produced bydump— that one versions the on-disk ABI surface, and its current value lives on its own fact-owner page (Snapshot format), not here.abicheck dump --format jsonwrites a snapshot (carryingschema_version), not a report, so it has noreport_schema_version. A report and a snapshot can carry different version numbers at the same time; consumers should read whichever field belongs to the file they loaded.
scan --format jsonis a third, separate shape: it emits aScanOutcomeobject (mode,level,risk,verdict,exit_code, …). It carries its own top-levelscan_schema_versionfield (MAJOR.MINOR, importable asabicheck.schemas.SCAN_SCHEMA_VERSION) — independent of, and not interchangeable with,report_schema_version. The typed PythonScanResult.to_dict()envelope (abicheck.service) stamps the same value at its own top level, in addition to nesting theScanOutcomedict (with its ownscan_schema_version) under itsreportkey. There is currently no packaged.schema.jsonfor scan output (unlikecompare'scompare_report.schema.json); the version field is honored the same way (accept a sharedMAJOR, ignore unknown keys) until one exists.
Scoped vs. full-library results (full_verdict/full_severity/full_summary)¶
A --used-by/--required-symbol(s) scoped compare gates its exit code and
verdict/severity/summary on the scoped subset of changes (plus any
scoped-only synthetic findings, e.g. consumer_required_symbol_removed) —
that scoped result is what a CI gate should act on. When the scoped result
differs from the unscoped, full-library comparison, the original full-library
values are preserved alongside it: full_verdict (schema 2.1+, same enum as
verdict), full_severity (schema 2.1+, same shape as severity), and
full_summary (schema 2.9+, same shape as summary). All three are absent
for an unscoped compare, where verdict/severity/summary already
describe the whole library. summary itself is always recomputed from the
complete (post-scoping) changes array, so it never contradicts the changes
a consumer actually sees — full_summary exists only to preserve the
pre-scoping counts a consumer might also want.
Stability policy:
- Additive changes — new optional keys, new enum members, relaxing a constraint — bump the MINOR component. Existing consumers keep working.
- Breaking changes — removing or renaming a key, tightening a type, or removing an enum member — bump the MAJOR component.
Consumers should accept any report whose report_schema_version shares their
expected MAJOR component and ignore unknown keys (the schema sets
additionalProperties: true precisely so that MINOR additions never break
validation). Validating with the bundled schema requires the optional
jsonschema package:
import json, jsonschema
from abicheck.schemas import load_compare_report_schema
report = json.loads(open("report.json").read())
jsonschema.validate(report, load_compare_report_schema())
aggregate's own report shape¶
abicheck aggregate --format json is a separate document, not a
compare/scan report — it's versioned by its own
aggregate_schema_version and describes a fan-in over already-produced
reports rather than one comparison. Its four independent axes
(compatibility/coverage/gate/contract_coverage) and the
profile_matrix/finding_matrix reconciliation blocks are documented,
with a fully annotated example, in Aggregate
Reports rather than repeated here.
Release recommendation (--recommend)¶
Translates the verdict into the maintainer's actual question — what version do
I release, and do I need to bump the SONAME? — as a recommended semantic-version
bump (major/minor/patch/none) plus a SONAME action.
The recommendation is policy-aware (it honours --policy and
--policy-file):
| Verdict | Bump | SONAME |
|---|---|---|
NO_CHANGE |
none | no bump needed |
BREAKING |
major | bump required (or bump_missing/bump_performed if abicheck observed the soname) |
API_BREAK |
major | no bump needed (binary stays loadable) |
COMPATIBLE_WITH_RISK |
minor/patch | no bump needed |
COMPATIBLE (additions) |
minor | no bump needed |
COMPATIBLE (quality only) |
patch | no bump needed |
In JSON output the recommendation is always present (no flag needed) under
the release_recommendation key, so CI and agents can gate on it — check
state first, before ever reading version_bump:
abicheck compare old.so new.so -H include/ --format json \
| jq -r '.release_recommendation
| if .state == "actionable" then
"release: bump \(.version_bump), soname \(.soname_action)"
elif .state == "review" then
"needs human review: \(.rationale)"
else
"no confirmed evidence: \(.rationale)"
end'
# release: bump major, soname bump_required
The elif/else branches never read .version_bump — for "review" it's
still a real value but unconfirmed against binary evidence, and for
"unavailable" it's null (schema 2.20+); either way the branch that treats
it as actionable is exactly the one this example skips.
state is one of actionable (act on version_bump/soname_action
directly), review (a source/API break was found but no binary evidence
confirms a SONAME action either way — version_bump is still a real value,
route to a human), or unavailable (abicheck had no binary evidence at all
to back a confident bump — version_bump is null, not a
plausible-looking string, so automation that reads version_bump without
checking state first can silently treat a real, unconfirmed break as "no
action needed" or crash on the unexpected null).
possible_impact is always a non-null string — the bump abicheck would
recommend if its evidence were sufficient to confirm one. It equals
version_bump when state is actionable; for review/unavailable it is
the machine-readable form of what rationale prose already says (e.g. sizing
a human-review queue), not a substitute for checking state before
acting — an automated release job must still gate on version_bump/state,
never on possible_impact alone. rationale always explains what abicheck
would still recommend even when state isn't actionable. See the
compare-report JSON Schema's
release_recommendation object for the full field contract.
SARIF Output¶
abicheck supports SARIF 2.1.0 output for integration with GitHub Code Scanning and other SAST platforms.
Usage¶
GitHub Code Scanning integration¶
# .github/workflows/abi-check.yml
name: ABI Check
on: [pull_request]
jobs:
abi-check:
runs-on: ubuntu-24.04
permissions:
security-events: write # required by the upload-sarif step below
contents: read
defaults:
run:
shell: bash -el {0}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: conda-incubator/setup-miniconda@fc2d68f6413eb2d87b895e92f8584b5b94a10167 # v3
with:
activate-environment: abicheck
- name: Install abicheck
run: |
# Avoid Ubuntu's Clang-17 CastXML build; conda-forge supplies a
# compatible toolchain. The abicheck Action uses a checksum-pinned
# official Superbuild instead.
conda install -y -c conda-forge castxml
pip install abicheck
- name: Dump ABI (baseline)
run: |
abicheck dump lib/libfoo.so.1 -H include/foo.h \
--version ${{ github.base_ref }} -o old.json
- name: Dump ABI (PR)
run: |
abicheck dump lib/libfoo.so.2 -H include/foo.h \
--version ${{ github.head_ref }} -o new.json
- name: Compare ABI
run: |
abicheck compare old.json new.json --format sarif -o abi.sarif
continue-on-error: true
- name: Upload SARIF
uses: github/codeql-action/upload-sarif@7188fc363630916deb702c7fdcf4e481b751f97a # v4
with:
sarif_file: abi.sarif
Pin every action in this job to a commit SHA
This job grants security-events: write for the upload-sarif step, so
every uses: here runs with that permission's token — a repointed or
compromised mutable tag (@v4, @v3) would run with it too. Pin to a
full commit SHA, keeping the release tag in a trailing comment for
auditability (as above). See Versioning.
Severity mapping¶
| ABI Change | SARIF Level |
|---|---|
| Function/variable removed | error |
| Type size/layout changed | error |
| Return/parameter type changed | error |
| Function/variable added | warning |
SARIF document structure¶
{
"$schema": "https://raw.githubusercontent.com/.../sarif-schema-2.1.0.json",
"version": "2.1.0",
"runs": [{
"tool": { "driver": { "name": "abicheck", "rules": [...] } },
"results": [{
"ruleId": "func_removed",
"level": "error",
"message": { "text": "Function foo() removed" },
"locations": [{
"physicalLocation": { "artifactLocation": { "uri": "libfoo.so.1" } },
"logicalLocations": [{ "name": "_Z3foov" }]
}],
"properties": {
"caused_by_type": null,
"caused_count": 0
}
}]
}]
}
JUnit XML Output¶
abicheck can produce JUnit XML reports for CI systems that display test results in their standard dashboards — GitLab CI, Jenkins, Azure DevOps, CircleCI, and others.
Usage¶
abicheck compare old.json new.json --format junit -o results.xml
abicheck compare release-1.0/ release-2.0/ --format junit -o abi-tests.xml
How it works¶
ABI changes are mapped to JUnit test cases:
- Each library in a bundle
compare(directory/package inputs) becomes a<testsuite> - Each exported symbol or type that was checked becomes a
<testcase> - BREAKING and API_BREAK changes produce
<failure>elements - COMPATIBLE changes (additions, no-change) are passing test cases
- COMPATIBLE_WITH_RISK changes pass by default (unless their per-kind
severity is overridden to
"error") - Unchanged symbols from the old library also appear as passing test cases, so the pass-rate is meaningful
- When a symbol has multiple breaking changes, the
<testcase>contains multiple<failure>children (one per change)
Severity mapping¶
| ABI Verdict | JUnit Outcome |
|---|---|
| BREAKING | <failure type="BREAKING"> |
| API_BREAK | <failure type="API_BREAK"> |
| COMPATIBLE_WITH_RISK (severity=warning) | Pass |
| COMPATIBLE | Pass |
Classname groups¶
Test cases are grouped by classname for CI dashboards that support
hierarchical display:
| Element | classname |
|---|---|
| Functions | functions |
| Variables | variables |
| Types (struct/class/union) | types |
| Enums | enums |
| ELF metadata (SONAME, etc.) | metadata |
JUnit XML structure¶
<?xml version="1.0" encoding="UTF-8"?>
<testsuites name="abicheck" tests="47" failures="3" errors="0">
<testsuite name="libfoo.so.1" tests="47" failures="3" errors="0">
<!-- Passing: no ABI change detected -->
<testcase name="_ZN3foo3barEv" classname="functions" />
<!-- Failure: binary-incompatible change -->
<testcase name="_ZN3foo3bazEi" classname="functions">
<failure message="func_param_type_changed: parameter 1 type changed from int to long"
type="BREAKING">
parameter 1 type changed from int to long
(int → long)
Source: include/foo.h:42
</failure>
</testcase>
<!-- Failure: removed symbol -->
<testcase name="_ZN3foo6legacyEv" classname="functions">
<failure message="func_removed: Function foo::legacy() was removed"
type="BREAKING">
Function foo::legacy() was removed
</failure>
</testcase>
<!-- Passing: addition is compatible -->
<testcase name="_ZN3foo9new_thingEv" classname="functions" />
</testsuite>
</testsuites>
CI integration examples¶
GitLab CI¶
abi-check:
script:
- abicheck compare old.so new.so -H include/ --format junit -o abi-results.xml || true
artifacts:
when: always
reports:
junit: abi-results.xml
Jenkins (JUnit plugin)¶
stage('ABI Check') {
steps {
sh 'abicheck compare old.so new.so -H include/ --format junit -o abi-results.xml'
}
post {
always {
junit 'abi-results.xml'
}
}
}