The exit code says a run failed, not what failed. A caller that wants to route on the result asks for a report rather than scraping the console or re-running the tool.

mix quality --report .quality.json   # human output on stdout, report to a file
mix quality --format json            # report on stdout, human output on stderr

Both can be given at once. --report is usually the more useful of the two, because it leaves the human stream intact.

The report is built from the same results the human output is rendered from, so the two can never disagree.

Top level

{
  "status": "error",
  "version": "0.6.0",
  "duration_ms": 5014,
  "stages": []
}
FieldTypeNotes
status"ok" | "error""error" if any stage failed
versionstringthe ExQuality version that produced the report
duration_msintegerwall clock time of the whole run, which is not the sum of the stage durations, because the analysis stages run in parallel
stagesarrayevery stage the run considered, in the order it reported them

Stage

{
  "name": "Dependencies",
  "status": "error",
  "summary": "1 vulnerability (1 moderate)",
  "stats": {"vulnerabilities": 1, "vulnerabilities_by_severity": {"moderate": 1}},
  "findings": [],
  "duration_ms": 2400
}
FieldTypeNotes
namestringFormat, Compile, Credo, Dialyzer, Dependencies, Doctor, Gettext, Sobelow, Tests
status"ok" | "error" | "skipped"
summarystringthe same one-line summary the console prints; for a skipped stage, the reason
statsobjectstage-specific counts, {} when the stage has none
findingsarrayparsed problems; empty when there are none, or when the stage could not parse its output
duration_msinteger0 for a skipped stage
outputstringpresent only on a failure with no findings

Every stage carries the same keys whatever its status, so a consumer reads one field for the explanation rather than branching. A skipped stage puts its reason in summary:

{
  "name": "Dialyzer",
  "status": "skipped",
  "summary": "--quick",
  "stats": {},
  "findings": [],
  "duration_ms": 0
}

Reasons are the flag (--quick, --skip-credo), the missing package (:gettext not installed), disabled in .quality.exs, or compile failed.

Every stage the run considered appears, skipped ones included, so absence is never something a caller has to interpret.

Finding

{
  "file": "lib/user.ex",
  "line": 42,
  "column": 3,
  "app": "web",
  "severity": "info",
  "check": "Credo.Check.Readability.ModuleDoc",
  "message": "Modules should have a @moduledoc tag."
}
FieldTypeNotes
filestringthe file the problem is in
lineinteger | nullnull when the tool reported none
columninteger | nullnull when the tool reported none
appstring | nullthe umbrella app the file belongs to, when known
severity"error" | "warning" | "info"
checkstring | nullthe tool's rule identifier: a check module, a warning name, an advisory ID
messagestringthe problem

Findings and output

findings is the parsed form; output is the fallback. For a failure, one of the two is always present.

A stage whose parser did not account for its tool's output carries that output verbatim under output instead of dropping it. Findings never replace output they did not account for, so a parser being wrong about a format degrades into "shows the original lines" rather than silently hiding a real problem.

Routing on a report

Read the report rather than re-running a tool to find out what broke.

report = "quality.json" |> File.read!() |> Jason.decode!()

for stage <- report["stages"], stage["status"] == "error" do
  case stage["findings"] do
    [] -> {stage["name"], {:output, stage["output"]}}
    findings -> {stage["name"], {:findings, findings}}
  end
end