Add --export-json for structured verification results - #4472
Conversation
…o add runner results
…n-handler # Conflicts: # kani-driver/src/main.rs
…schedule the schema for harness metadada util func.
…kani-output into feat/json-handler
…s_runner.rs to minimize dependency
This reverts commit a2e7757.
…failures
Three defects that all let this feature report something untrue.
**`--export-json` accepted two modes that cannot produce a real export.**
With `--only-codegen`, verification never runs, so the command exited 0
and silently wrote no file at all. With `--output-format=old`, `run_cbmc`
mocks a `VerificationResult` with no properties and treats a timeout as
success, so the export was produced from fabricated data: a summary with
zero checks that a consumer cannot distinguish from a clean run. Both are
now rejected up front, as `--sarif` already does for the same two modes.
**The tests could not fail on validation errors.** `basic-export` and
`multiple-harnesses` pipe the validator into `tail`, and without
`pipefail` the pipeline reports tail's exit status. The validator's own
result was discarded, so the validation step in two of the four tests was
inert:
$ python3 validate_json_export.py bad.json 2>&1 | tail -1
Validation failed for bad.json
$ echo $?
0
**Exports from a run with no CBMC results failed the bundled validator.**
On timeout, out of memory, or a CBMC error, `property_details` dropped to
`total_properties` plus an `error` string, while the schema requires
`passed`, `failed`, `unreachable` and `undetermined` — so precisely the
runs a consumer most needs to interpret produced a file that Kani's own
validator rejects. The counts are now always present, and reported as
null rather than 0: `0 failed` asserts that nothing failed, when the truth
is that nothing was measured. `error` is marked optional in the schema.
Note the third defect was made more visible by the earlier commit here
that added `undetermined` to the counts, taking it from three missing
fields to four.
Verified: both conflicts are now rejected with an explicit message before
verification starts, and a forced CBMC error produces an export that
passes the validator.
Three exported values described something other than the run that
happened.
**`solver` ignored `--solver`.** The export read `h.attributes.solver` and
defaulted to the string "Cadical", while `handle_solver_args` gives
`--solver` precedence over the harness attribute. A run verified with
`--solver minisat` therefore exported `"solver": "Cadical"` — not missing
data, but wrong data, about a setting that changes how a result should be
read. The precedence chain is now a single `resolved_solver` method that
`handle_solver_args` also uses, so the command line and the export cannot
drift apart.
**`object_bits` reported null for explicitly configured runs.**
`cbmc_object_bits()` deliberately returns `None` once `--object-bits` comes
through `--cbmc-args`, since Kani then stops passing its own default. That
is right for building the command and wrong for describing it, so
`effective_object_bits` falls back to the value in `--cbmc-args`.
**`workspace_root` was the compiler output directory.** `Project::outdir`
is documented as the directory outputs are written to; for Cargo it sits
under `target/<triple>/debug/deps`. It is now reported as `output_dir`,
and `workspace_root` comes from Cargo metadata, which carries the real
one — null for a standalone run, which has no workspace.
Verified on a Cargo project:
"workspace_root": "/private/tmp/cargocheck",
"output_dir": "/private/tmp/cargocheck/target/kani/aarch64-apple-darwin/debug/deps"
and `--solver minisat` now exports `"solver": "Minisat"`, while
`--cbmc-args --object-bits 20` exports `"object_bits": 20`.
Two tests asserted much less than they appeared to.
**The validator only checked the first element of every array.** Malformed
data in any later harness passed validation, which defeats the purpose on
exactly the multi-harness exports it exists to check. Confirmed against
the previous version: an export with `cbmc[1].configuration` removed
passed, and now reports
Missing required field: cbmc[1].configuration
**`multiple-harnesses` only counted `harness_metadata` entries.** The PR
offers "counts in summary match executed harnesses" as its validation, and
nothing tested that. It now checks the summary counters, the results
length and their statuses, and that `error_details`, `property_details`
and `cbmc` each cover all three harnesses exactly once -- by `harness_id`,
since those arrays are built in a different order from `results`, so
identity cannot come from position.
Verified the new assertions actually fail, by mutating a real export four
ways: a wrong summary counter, a dropped `error_details` entry, a flipped
harness status, and a missing `harness_id`. Each is reported as a distinct
message rather than a traceback, including when a missing id leaves `None`
in a set that then gets sorted for the error text.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 27 out of 27 changed files in this pull request and generated no new comments.
Suppressed comments (5)
kani-driver/src/harness_runner.rs:115
- With
--fail-fast, this vector contains only the failing harness. Any successful harnesses executed before the failure are discarded by thecollect::<Result<Vec<_>>>()above, so the exportedexecutedcount becomes 1 and those real executions disappear fromresults. Preserve completed results (including the failure) when building the fail-fast report so the machine-readable summary reflects what actually ran.
let result = vec![HarnessResult {
harness: sorted_harnesses[failed.index_to_failing_harness],
result: failed.result,
}];
kani-driver/src/frontend/schema_utils.rs:224
- The exported property totals cannot be reconciled for valid CBMC statuses.
CheckStatusalso includesUnknown,Error,Covered,Uncovered,Satisfied, andUnsatisfiable; none are counted here (andUnknown, which Kani renders as undetermined, is omitted fromundetermined). As a result, these category counts can sum to less thantotal_properties. Export every status or define an exhaustive grouping so consumers do not receive incomplete statistics.
json!({
"total_properties": properties.len(),
"passed": count(CheckStatus::Success),
"failed": count(CheckStatus::Failure),
// Counted directly rather than derived by subtraction, which silently
// reported undetermined and error properties as unreachable.
"unreachable": count(CheckStatus::Unreachable),
"undetermined": count(CheckStatus::Undetermined)
})
kani-driver/src/frontend/schema_utils.rs:290
- This can report the wrong solver when users select one through the supported
--cbmc-argsescape hatch (for example,--cbmc-args --sat-solver minisat). Those arguments are appended after Kani's generated solver flags, butresolved_solveronly considers--solver, the harness attribute, and the default. Resolve direct CBMC solver arguments too, or reject them with--export-json, soconfiguration.solverdescribes the command that actually ran.
"configuration": {
"object_bits": effective_object_bits(session),
"solver": format!("{:?}", session.resolved_solver(&h.attributes.solver)),
},
kani-driver/src/args/mod.rs:248
- The PR description marks several unrelated issues as resolved, including #1219 (a compiletest
--helppanic), #2636 (human-friendly coverage output), and #3357 (parallel non-terse output), but this option and the accompanying changes do not implement those requests. Remove the unrelatedResolvesentries or implement their acceptance criteria; otherwise merging this PR will incorrectly close open work.
/// Output the verification results to a JSON file at the specified path.
/// This feature is unstable and it requires `-Z unstable-options` to be used
#[arg(long)]
pub export_json: Option<PathBuf>,
scripts/validate_json_export.py:90
- The validator never checks leaf types or values, so malformed exports such as string summary counts, numeric status values, or null metadata pass as long as the keys exist. That means the new integration tests do not actually validate the structured contract represented by the template. Validate primitive types (and fixed enum/version values where applicable), or use a real JSON Schema validator.
# Leaf values - no validation needed
Two comments I'm deliberately not acting on in this PRBoth are legitimate, but both are decisions for RFC #4727 rather than fixes: Type validation / adopting a real JSON Schema. Correct that the current validator does no scalar Exporting coverage results. Also correct that the export only records whether coverage was Three comments that don't holdFlagging these so they don't get re-raised:
The branch builds clean and passes |
Partially addresses model-checking#2572, which asks for the versions of all the tools Kani relies on rather than just CBMC's. This covers the machine-readable half; the issue also asks for them to be printed after harness metadata collection, which this does not do, so the issue stays open. The new top-level `tools` object reports: - `kani`, and `rustc` from `kani-compiler --version`. kani-compiler is a rustc driver, so it reports the toolchain it was built against, which is the version that decides how Rust is translated. Asking the binary beats reading a `rustc` from PATH, which need not be the same toolchain. - The CBMC suite Kani actually invokes: `cbmc`, `goto_cc`, `goto_instrument`, and `goto_synthesizer` when loop-contract synthesis runs. - `solvers`, one entry per distinct solver the run resolves to, which can differ per harness. A list rather than a map, since the set varies per run and consumers should not have to guess which keys might appear. Three conventions worth stating, since this is interface: - A key is present only when the run uses that tool, so an absent key means "not part of this run" while a present null means "used, but its version could not be determined". `goto_synthesizer` is marked optional in the schema for this reason. - CaDiCaL and MiniSAT are built into CBMC and would report CBMC's version, so they are named with a null version rather than given a misleading one. - Versions are verbatim first lines of `--version` output, so they are display strings and not to be parsed. `goto-cc`, for instance, reports `clang version 21.0.0 (goto-cc 6.10.0 (cbmc-6.10.0))`. Probing costs one process per tool, paid only when `--export-json` is requested, once per run rather than per harness, and each binary once however many harnesses use it. A probe that fails yields null and never fails the run. Verified across four configurations: a default run, `--solver kissat`, `--synthesize-loop-contracts`, and a two-harness run where one harness carries `#[kani::solver(kissat)]` -- which reports both solvers, matching the per-harness `configuration.solver` values.
Two exported values could not be trusted to describe the run.
**The per-status property counts did not add up.** `CheckStatus` has ten
variants and only four were counted, so any run with cover statements,
coverage properties or a solver error produced counts that silently summed
to less than `total_properties` -- and a consumer had no way to tell that
from a run where they genuinely reconciled. A harness with two covers went
from
total_properties: 3, passed: 1, failed: 0, unreachable: 0, undetermined: 0
to
total_properties: 3, passed: 1, satisfied: 1, unsatisfiable: 1, ...
The counts now partition the properties exhaustively, via a `match` with no
wildcard so that a new `CheckStatus` fails to compile here rather than
quietly going uncounted. `Unknown` is grouped with `undetermined` because
that is how Kani renders it. The `CheckStatus::Error` count is exported as
`solver_error`, since `error` is already the message field on the
unmeasured path. The no-results path reports every count as null, as
before.
I should note this was partly self-inflicted: adding `undetermined` in an
earlier commit here made the set look authoritative when it still wasn't.
**`configuration.solver` ignored the `--cbmc-args` escape hatch.**
`--cbmc-args` is appended after Kani's own solver flags and CBMC takes the
last one it sees, so `--cbmc-args --z3` overrides `--solver` and the
harness attribute. `resolved_solver` knows nothing about that, so the
export named the wrong solver. `effective_solver` now scans `--cbmc-args`
for `--sat-solver`, `--external-sat-solver`, `--z3`, `--cvc5`, `--bitwuzla`
and `--smt2`, last one winning, and reports null when the override leaves
the choice to CBMC -- a wrong name is worse than no name.
Verified across six configurations:
baseline "Cadical"
--solver kissat "Kissat"
--cbmc-args --z3 "z3"
--solver kissat --cbmc-args --z3 "z3"
--cbmc-args --smt2 null
--cbmc-args --sat-solver cadical "cadical"
The multiple-harnesses test now asserts the reconciliation invariant, so a
future status that escapes the partition fails a test rather than shipping
incomplete statistics.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 27 out of 27 changed files in this pull request and generated 2 comments.
Suppressed comments (4)
kani-driver/src/frontend/schema_utils.rs:96
tools.solversignores solver overrides passed through--cbmc-args. Those arguments are appended after Kani's solver flags (call_cbmc.rs:357), and this file already resolves them forcbmc[].configuration.solver, so an invocation such as--solver cadical --cbmc-args --z3exports contradictory metadata: Cadical here but Z3 in the per-harness configuration and actual run. Derive the version probes from the same effective-solver resolution, including--external-sat-solver.
let (name, binary) = match session.resolved_solver(&harness.attributes.solver) {
kani-driver/src/harness_runner.rs:122
- With
--fail-fast,resultcontains only the failing harness because theResult<Vec<_>>collection discarded any successful results completed before the error. Serializing this singleton makessummary.executed,successful,duration_ms, and the per-harness arrays underreport what actually ran (especially with parallel jobs). Preserve completed results through the fail-fast path before generating the JSON summary.
add_runner_results_to_json(
handler,
&result,
harnesses.len(),
"completed_with_fail_fast",
scripts/validate_json_export.py:52
- When the template expects an object but the exported value is not an object, this condition falls through to the leaf case and reports success. For example,
{"metadata": null, ...}bypasses every required metadata field, so the schema tests can accept structurally malformed exports. Reject object type mismatches and explicitly model the few fields that are legitimately nullable.
# Handle dict validation
if isinstance(schema, dict) and isinstance(data, dict):
tests/json-handler/basic-export/test.sh:21
- All new end-to-end scripts invoke standalone
kani; none exercises the advertisedcargo kani --export-jsonpath. That leaves Cargo argument handling and Cargo-only project metadata such asworkspace_rootunverified. Add a cargo-kani integration case that exports and validates a small Cargo project.
# Run Kani with JSON export
kani -Z unstable-options test.rs --export-json "$OUTPUT_FILE"
…tor holes
Five issues from review, all reproduced first.
**A run with no harnesses wrote a document missing four of its own keys.**
The per-harness arrays are filled in lazily, so `kani --harness
does_not_exist --export-json out.json` produced a file without
`harness_metadata`, `error_details`, `property_details` or `cbmc` -- one
that Kani's own validator rejects. Worse, the "no harnesses matched" error
is only reported after the export, so a consumer sees the malformed file
before Kani admits nothing ran. All four keys are now declared up front.
**`--no-codegen` is now rejected alongside `--only-codegen`.** For
`cargo kani` it returns an empty `Project`, so
`cargo kani --no-codegen --export-json out.json` wrote a document claiming
a completed run with zero harnesses. Standalone `kani --no-codegen` already
failed for unrelated reasons, but the combination was still accepted.
**`tools.solvers` and `configuration.solver` could contradict each other.**
`configuration.solver` accounted for solvers named in `--cbmc-args` while
`tools.solvers` did not, so `--solver cadical --cbmc-args --z3` exported
Cadical in one place and z3 in the other. Both now go through a single
`effective_solver`, which also distinguishes `--sat-solver` (built into
CBMC, so no binary to probe) from `--external-sat-solver` (a binary, so its
version is probed):
--solver cadical --cbmc-args --z3 both report z3
--cbmc-args --sat-solver minisat minisat, version null
--cbmc-args --external-sat-solver kissat kissat, version 4.0.1
--cbmc-args --smt2 null, and no solver listed
**The validator passed structurally malformed exports.** A template object
paired with a non-object fell through to the leaf case and reported
success, so `{"metadata": null}` bypassed every field required beneath it.
Object and array mismatches are now errors. Since `cbmc_stats` is
legitimately null when CBMC reported no statistics, the schema gained a
`_nullable` marker rather than making nulls universally acceptable.
Verified it now rejects a null object, an object where an array belongs,
and a null array, while still accepting a real export and one from a failed
CBMC run.
**Nothing exercised `cargo kani --export-json`.** Every test drove
standalone `kani`, which can never check the Cargo-only project metadata --
`workspace_root` is null for standalone runs, so the field fixed earlier in
this branch had no automated coverage. The new `cargo-export` test
scaffolds a crate outside the repository, exports, validates, and asserts
that `workspace_root` is the crate root and differs from `output_dir`.
Confirmed it fails when that expectation is broken.
Two known gaps remain, both deliberate. The validator still does not check
leaf types, so a count could be the string "1" -- that is the JSON Schema
question RFC 0016 leaves open, since doing it properly means a `schemars`
dependency. And `--fail-fast` still under-reports what ran; that is
pre-existing behaviour affecting the rendered summary too, now tracked in
model-checking#4729.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 30 out of 30 changed files in this pull request and generated no new comments.
Suppressed comments (3)
kani-driver/src/frontend/schema_utils.rs:269
pretty_nameis crate-relative (readable_namestrips the local crate prefix), so two crates in one workspace can expose the same harness name. Using it asharness_idmakes the export ambiguous and also causes the laterfindcalls keyed only bypretty_nameto attach both crates' details to the first matching result. Use a globally unique identifier, such as a crate-qualified name, consistently across metadata, results, and detail arrays.
"harness_id": result.harness.pretty_name, // Reference to harness instead of duplicating name
kani-driver/src/harness_runner.rs:115
- This rebuilds the fail-fast output from only the failing harness, but
collect::<Result<Vec<_>>>()has already discarded every successful result produced before that failure. Even with--jobs 1, if the second harness fails, the JSON reportsexecuted: 1and omits the first harness although it ran. Preserve completed results when stopping so the structured summary reflects all executions.
let result = vec![HarnessResult {
harness: sorted_harnesses[failed.index_to_failing_harness],
result: failed.result,
}];
kani-driver/src/frontend/schema_utils.rs:48
- A tool that rejects
--versionbut prints an error or usage line to stdout is recorded as having that text as its version. This contradicts the documented null-on-undetermined behavior and can mislead JSON consumers; require a successful exit status before parsing stdout.
let output = Command::new(binary).arg("--version").output().ok()?;
3bebcca
Rename 0016-export-json.md to 0015-export-json.md and list it (plus the pre-existing 0014-harness-partition) in SUMMARY, so this supersedes the 0015 slot claimed by model-checking#4472's json-handler draft. Review fixes (overnight lens + model review, local only): - Remove the two <!-- Q2/Q3 --> review-scaffolding comments (belong in the PR thread, not the committed RFC; one re-asserted the [S] soundness label the maintainer ruled wrong). - Value domains: distinguish the covers buckets (satisfied/unsatisfiable) from the checks buckets (success/failure) in the exhaustive-partition prose, matching the example and the enum table. - Stabilization: attribute the open-questions-before-stabilization requirement to the RFC process/template, not RFC 0006 (0006's API Stabilization section does not state it; template.md does).
model-checking#4472 merged on 2026-08-12 and ships --export-json behind -Z unstable-options. The RFC no longer proposes a future flag. It now specifies the contract for a shipped one. - Summary and prior-work section state the as-built reality. - The -Z gate section states the shipped gate and proposes the dedicated ident as a migration step. - New first open question: does this schema supersede the shipped v1 shape, or is the RFC redrawn around it. - Drop the reference to closed PR model-checking#4732; run_state is proposed here, not implemented anywhere. - 'Do nothing' section names the second cost: the shipped shape becomes a de-facto unversioned contract.
Address two gaps found by building the writer and by the downstream-consumer analysis: - `tools`: a single object with kani/rustc/cbmc/goto-cc/goto-instrument versions and a solvers[] list, restoring the machine-readable tool provenance the shipped model-checking#4472 shape carried (kani issue model-checking#2572) that this schema had dropped. - `selector`: the exact `--harness` string per harness (the module-qualified path), so an out-of-tree consumer has one stable, re-runnable key rather than reconstructing it from the file path. Also record, as open questions, the three shipped fields still dropped (workspace provenance, autoharness is_bounded/is_ctor_based, coverage.enabled) so their removal is a decision rather than a silent regression.
Reorganize the document so the human-read body is ~half length, with the exhaustive machine-contract detail relocated (not deleted) to a new "Normative schema reference" appendix. Body keeps the design narrative: Summary/User Impact, the vacuity gap, the model-checking#4472 relationship, User Experience and flag interactions, the JSON example, the two vacuity predicates, the key decisions (is_bounded/coverage_enabled mandatory, name is the selector), the run_state completeness contract, and the Rationale, open questions, and out-of-scope sections. Appendix collects the field-reference tables, the presence matrices, the value-domain/enum table, the run_state x outcome co-occurrence table, and the failure_kind truth table, plus the per-field contract prose. All tables, JSON, and code blocks are byte-identical; no content removed. Also a light plain-English polish pass on relocated prose.
Add opt-in JSON export (--export-json ) to emit structured verification results (metadata, per-harness outcomes, CBMC stats).
Improves Kani by enabling reliable machine-readable output for external tools and applications.
Context: Current output is human-readable only, which blocks robust automation and integrations.
Manual tests:
• Run cargo kani --export-json out.json
• With multiple harnesses: counts in summary match executed harnesses.
Resolves #2572
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 and MIT licenses.