Output formats
Beyond the default text report, richter:detect-changes writes three other formats. --markdown is for pull requests, --html is for a visual read, and --json is the machine contract.
--markdown and --html output
With --markdown, the report renders as GitHub-flavoured markdown: a risk badge up front, changed files as a table, entry points as a review checklist with their file:line, test tags and exposure badges, and long lists collapsed into <details> instead of truncated. The result is ready to paste into (or post onto) a pull request. --markdown --explain composes.
Post it byte for byte. GitHub only parses markdown inside a <details> when a blank line follows </summary>, so a CI step that filters empty lines out of the report — a .split('\n').filter(Boolean), a grep -v '^$', a while read loop that skips blanks — makes every collapsed section render as literal markdown source. The blank lines are load-bearing output, not formatting slack. Redirect the report to a file and post the file unmodified.
Richter defends this end as far as it can. --markdown, --json, --plain and the config stanza richter:benchmark-add prints are written beneath Laravel's console writer, so a package that rewrites Artisan output cannot alter them. laravel/pao is the one to know about: it rebinds the writer when it detects an AI agent, and its cleaner collapses runs of spaces, deletes blank lines, and strips arrow and warning glyphs — invisibly, because it stays off in a human terminal and off in CI. The default text report is deliberately left to it, since compacting prose for an agent is what it is for. If a captured report looks mangled and richter's own output does not, compare richter:detect-changes --markdown | cat -A at the point of capture against the bytes your pipeline finally posts.
With --html=<path>, the report is written as one self-contained HTML file (every style and script inline, nothing fetched), so it opens offline straight from file:// and travels as a CI artifact you can link from a pull request. It has five tabs: Overview (a Files / Impacted / Depth / Risk stat row, the reached entry points, and what to focus on), Graph (the blast radius as concentric rings, one per BFS depth), Paths (how each entry point reaches the change), Changes (the member-level diff, naming the member that drove a low-confidence verdict), and Advisory (findings, test references, and the gate). --open launches it in the default browser afterwards; a failing opener is a warning, never a failed run.
Every file:line in the report is a clickable editor link. richter.editor reads the same env chain debugbar and Ignition do (CODE_EDITOR, then DEBUGBAR_EDITOR, then IGNITION_EDITOR) and, like debugbar, defaults to phpstorm, so an existing setup needs no new variable. Supported: phpstorm, idea, vscode, vscode-insiders, vscode-remote, vscodium, sublime, textmate, emacs, macvim, atom, nova, netbeans, xdebug. Set it to null to keep the file references plain text, worth doing for a shared CI artifact, since a link embeds an absolute local path that only opens on the machine that generated the report.
--html cannot be combined with --json or --markdown. It replaces the text report on stdout but never touches the gate: --html --fail-on=medium still exits non-zero exactly when the gate trips. The diagram is capped at 300 nodes and says so in the report when it caps; the counts above it are never capped. Note that the HTML is a rendering surface, not a contract: its markup is free to change in any release. The same holds for --markdown and the default text report — both are prose written for a reviewer, and their headings, summary lines and wording change whenever a clearer sentence is found. Do not parse either one in a pipeline: a release that reworded a section heading would break it silently. --json is the semver-governed machine output, and it carries the values a pipeline needs — risk and riskCause for the level, hazards[].reach for a reach class, associationEntryPointsVia for why a surface is listed. It is not a superset of the prose reports: suggestions, graphNodeCount, entryPointAuthMiddleware, newFiles and fqcns are rendered but not serialised. Ask for one if a pipeline needs it — that is a payload addition, not a reason to parse a sentence.
--json output
With --json, stdout is a single JSON document (the full, uncapped report) with these top-level keys, or {"error": "…"} if the diff can't be resolved:
| Key | Type | Meaning |
|---|---|---|
base | string | the ref the diff was taken against |
changed | object | {file: graph-node count} per changed file |
coverage | object | {file: "analyzed" | "unresolved"} per changed file |
entryPoints | string[] | entry-point nodes the change reaches through calls, plus the two sets appended after that walk: a changed class that is itself an entry surface (self-listed), and the routes a changed frontend file references. Neither appended set is graded by the risk level; verification names exactly what is |
associationEntryPoints | string[] | entry surfaces connected only by an association edge (model-relationship, model-to-policy or config-registry-fanout); associated with the change, not callers of it, and excluded from risk |
associationEntryPointsVia | object | why each associationEntryPoints entry is listed, keyed by node: the association edge types on the path the surface DEPENDS on. Where a surface is reachable both through a registry fan-out and without one, the fan-out-free path is reported — a fan-out is named only where it is required. This is what separates a link worth reading from one that says almost nothing — a model-relationship names ONE model, while a config-registry-fanout names no single class, so the same surfaces answer for every class the registry lists. The prose formats keep the first group inline and collapse the second under that one shared cause |
entryPointPaths | object | per reached entry point, the shortest call chain down to the changed code as {node, via, file?, line?} hops; a self-listed entry class carries no chain |
entryPointLocations | object | per entry point, its defining {file, line?} (project-relative), when known |
entryPointSecurity | object | per reached route, Brain's security surface {exposure, riskLevel, issues[]} (advisory annotation, routes only, never an input to risk or the gate); a Livewire/Filament/Nova/queue entry point has no key here at all, meaning "not classified," never "public" |
entryPointGates | object | per reached route, the Pennant feature flags gating it (advisory annotation, never an input to risk or the gate) |
entryPointTestReferences | object | per reached entry point, "referenced" / "referenced-no-behavioural-assertion" / "unreferenced"; an entry point whose reference state cannot be determined is omitted from the map (advisory annotation, never an input to risk, the gate, or affected-tests selection) |
impacted | int | count of risk-bearing nodes reached |
relatedModels | string[] | models reached only via association edges (context, not risk) |
traitAndOverrideReachVia | object | why each traitAndOverrideReach entry is listed, keyed by node: the edge types that reached it (uses-trait, override). This is what separates the two claims the section makes. A uses-trait entry has the changed method copied into it, so the changed code runs there. An override entry declares its own version of the member, so a change to the other end is a contract question rather than a behaviour one. The prose formats keep the first group inline and group the second by member name, folded under one summary |
traitAndOverrideReach | string[] | what the change is related to by inheritance rather than by a call: the ancestor member an override dispatches through, and a trait the changed class uses (context, not risk; the report prints these under "Related by inheritance, not by a call"). This key carries every entry, ungrouped — the grouping the prose formats apply is a rendering decision and never removes a name |
risk | string | "low" / "medium" / "high"; see Risk levels |
riskCause | string | why the level is what it is, in one line. Always present, because a level without its cause is not a usable verdict |
hazards | object[] | {lane, tier, cwe, member, reach, evidence} per hazard, worst tier first. reach is one of four: the findings public-write and gated, and the admissions no-guard-found (reached, no guard visible) and no-known-path (nothing reaching it found). Neither admission is evidence of safety. reach stays one of those four here and in MCP structured content: where the prose formats append (via its class) to say the grade came from the class's callers rather than from the walk's chains, this value does not, so matching on the four states keeps working. See Risk levels |
verification | object | what the level graded, mapped to whether a test references it: reached entry points, plus a changed class itself where it reached none. true is referenced, false unreferenced, null could not be checked |
lowConfidence | bool | a changed member couldn't be pinned, so part of the estimate is coarse |
findings | string[] | source-level findings |
unresolved | bool | any changed file is UNRESOLVED |
gate | object | present only under a --fail-on* flag (see Gating in CI) |