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.
…p tests
Three of the five comments were valid and are fixed here.
**The JSON pipeline ran unconditionally.** Metadata construction, the
per-harness JSON building and `process_cbmc_results` — which spawns
`cbmc --version` — all ran even without `--export-json`, with only the
final `export()` being a no-op. The handler is now built only when the
flag is present, so runs that don't use the feature do none of the work.
**`error_details` was overwritten per harness, and could report a failing
run as clean.** It used `add_item` inside the per-harness loop, so only
the last harness survived, with no harness id attached. On a three-harness
run where one harness genuinely failed, the export said
`{"has_errors": false}` — a false negative in machine-readable output,
which is the failure mode this feature exists to prevent. It now
accumulates one entry per harness keyed by `harness_id`, matching how the
`cbmc` array already works. Copilot suggested the smaller fix of not
letting a success overwrite a failure; that removes the false negative but
still reports only one harness per run and cannot say which, so this takes
the per-harness form instead.
**Exec tests only cleaned up on the happy path.** A failing validation
step exits early under `set -e` and left the export behind. Replaced the
trailing `rm -f` with an EXIT trap in all four tests.
While in `process_harness_results`, two related fixes:
- `property_details` entries now carry `harness_id`. The array is built in
harness-metadata order while `verification_results.results` is in
completion order, so correlating the two by position was silently wrong.
- `unreachable` was derived as `total - passed - failed`, which counted
undetermined and error properties as unreachable. It now counts
`Unreachable` directly, and reports `undetermined` separately.
Schema template and the failed-verification assertions updated for the new
`error_details` shape.
Also applied Copilot's `as_deref().unwrap_or("unknown")` suggestion in
`create_verification_result_json`. Not because the previous code was
broken — it compiled and behaved correctly, since the temporary outlives
the `to_value` call — but because it avoids three needless allocations per
property, and properties can run to thousands.
The remaining two comments claimed borrow/compile errors in
`create_harness_metadata_json`, `create_verification_result_json` and
`JsonHandler::export`. Those are incorrect: `json!` serializes through
`to_value(&expr)` rather than moving, and serde_json provides
`impl From<serde_json::Error> for std::io::Error`. The code builds clean
and clippy-clean with `-D warnings`.
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 (11)
kani-driver/src/args/mod.rs:248
--export-jsonneeds conflict validation for modes that cannot produce a real export. With--only-codegen,verify_projectis skipped and the command succeeds without creating the requested file; with--output-format=old,run_cbmcconstructs a mockVerificationResult, so the JSON can report a fabricated success with no checks. Reject these combinations, as is already done for SARIF.
/// 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>,
kani-driver/src/frontend/schema_utils.rs:46
- This exports the artifact output directory as
workspace_root.Project::outdiris explicitly the compiler output directory (project.rs:33-37), and for Cargo it points undertarget/.../debug/deps, not to the workspace. Use Cargo metadata'sworkspace_rootfor Cargo projects and define the standalone equivalent from the input source path.
"workspace_root": project.outdir.clone(),
kani-driver/src/frontend/schema_utils.rs:255
- These values do not reliably describe the CBMC invocation.
cbmc_object_bits()returnsNonewhen--object-bitsis supplied through--cbmc-args, and the solver ignoressession.args.solver, even though that option takes precedence over harness attributes incall_cbmc.rs:425-431. Export the same effective configuration used to build the command so consumers do not receivenull/Cadicalfor explicitly configured runs.
"object_bits": session.args.cbmc_object_bits(),
"solver": h.attributes.solver.as_ref().map(|s| format!("{:?}", s)).unwrap_or_else(|| "Cadical".to_string()),
kani-driver/src/main.rs:195
- The PR marks coverage issue #2636 as resolved, but the export only records whether coverage was enabled; none of the per-harness
coverage_resultsare serialized, and the existing user-facing coverage output is unchanged. Export the coverage outcomes or remove the issue-closing claim.
if let Some(handler) = handler.as_mut() {
handler.add_item("coverage", json!({"enabled": session.args.coverage}));
handler.export()?;
kani-driver/src/frontend/schema_utils.rs:224
- For timeout, OOM, or another CBMC execution error, this object omits
passed,failed,unreachable, andundetermined, although the bundled schema marks all four as required. Such exports therefore fail the new validator. Keep the shape stable and represent unavailable counts explicitly (or mark them optional in the schema).
Err(_) => json!({
"total_properties": 0,
"error": "Could not extract property details due to verification failure"
})
scripts/validate_json_export.py:85
- Only validating element zero lets malformed data in every later harness/result pass schema validation. Iterate over every array element against the item template so multi-harness exports are actually covered.
# Validate first item against schema template
sub_errors = validate_structure_recursive(data[0], schema[0], f"{path}[0]")[
1
]
errors.extend(sub_errors)
scripts/validate_json_export.py:87
- The validator performs no scalar type validation, and a schema object paired with a non-object also falls through here successfully. Consequently fields such as
metadata, numeric counts, and booleans can have invalid JSON types while the “schema validation” passes. Encode expected and nullable types (ideally with JSON Schema) and reject type mismatches.
# Leaf values - no validation needed
tests/json-handler/basic-export/test.sh:7
- Without
pipefail, a nonzero validator exit is hidden by the successfultail, so this integration test passes even when schema validation fails. Enable pipeline failure propagation.
set -eu
tests/json-handler/multiple-harnesses/test.sh:7
- Without
pipefail, a nonzero validator exit is hidden by the successfultail, so this integration test passes even when schema validation fails. Enable pipeline failure propagation.
set -eu
kani-driver/src/main.rs:149
- The PR description also declares unrelated issues resolved (for example #1219's compiletest
--helppanic and #3357's non-terse parallel output), but this change does not touch those paths and the existing non-terse parallel restriction remains. Remove the unrelatedResolvesentries to avoid closing still-open problems.
// Only build the JSON document when `--export-json` asks for one. Everything below it is
// overhead for every other run, including a `cbmc --version` probe in `process_cbmc_results`.
let mut handler =
session.args.export_json.as_ref().map(|path| JsonHandler::new(Some(path.clone())));
tests/json-handler/multiple-harnesses/test.sh:37
- This test only counts metadata entries, so it does not verify the aggregation behavior stated in the PR (summary counts matching executed harnesses). Assert the results length and all summary counters for the three successful harnesses.
HARNESS_COUNT=$(python3 -c "import json; data=json.load(open('$OUTPUT_FILE')); print(len(data['harness_metadata']))")
…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
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.