ExQuality.Finding (ExQuality v0.13.0)
View SourceA single actionable problem reported by a stage.
Stages parse their tool's output into findings so that ExQuality can render
the minimum a reader needs in order to act: a file:line, a message, and the
rule that produced it.
A finding always carries the raw text it was derived from. If a parser is
wrong about a tool's format, the finding degrades into "shows the original
lines" rather than silently dropping a real problem.
Stages that cannot parse their output leave findings empty, and the caller
falls back to printing the tool's full output verbatim. Unparseable output is
never hidden.
Fields
file- path relative to the project rootline- 1-based line number, ornilwhen the tool did not report onecolumn- 1-based column, when the tool reports oneapp- umbrella app the file belongs to, when knownseverity-:error,:warningor:infocheck- the tool's rule identifier (a check module, a category, a code)message- the human-readable problem descriptionraw- the tool output lines this finding was derived from
Summary
Functions
Builds a finding from a decoded JSON object, or returns :error.
Groups findings by umbrella app, sorted by app name.
Groups findings by file, sorted by file name and by position within a file.
Normalises a path reported by a tool into one relative to the run's root.
Renders findings as a human-readable string, grouped by file.
Sorts findings by file, then line, then column.
Types
@type severity() :: :error | :warning | :info
@type t() :: %ExQuality.Finding{ app: atom() | nil, check: String.t() | nil, column: pos_integer() | nil, file: String.t(), line: pos_integer() | nil, message: String.t(), raw: String.t(), severity: severity() }
Functions
Builds a finding from a decoded JSON object, or returns :error.
This is the reading half of the encoding in ExQuality.Report: it is what a
custom stage's command uses to report structured findings, and what a
consumer of a report uses to read one back.
Only file and message are required, matching the struct's enforced keys.
Everything else is optional and takes the struct's default when it is absent
or the wrong shape, because a tool that got one field wrong should still have
its finding read rather than dropped.
app is inferred from the path against apps when the object does not name
one, which is what the built-in stages do anyway and is one less thing for a
custom tool to get wrong. Pass ExQuality.Umbrella.apps_paths/0 as apps,
read once rather than once per finding.
iex> alias ExQuality.Finding
iex> {:ok, finding} = Finding.from_map(%{"file" => "lib/a.ex", "message" => "no"})
iex> {finding.file, finding.message, finding.severity}
{"lib/a.ex", "no", :warning}
iex> ExQuality.Finding.from_map(%{"message" => "nowhere to look"})
:error
Groups findings by umbrella app, sorted by app name.
Findings with no app come first, as one group under nil, so a single-app
project produces exactly one group.
iex> alias ExQuality.Finding
iex> findings = [
...> %Finding{file: "apps/b/lib/b.ex", app: :b, message: "b"},
...> %Finding{file: "apps/a/lib/a.ex", app: :a, message: "a"}
...> ]
iex> Enum.map(Finding.group_by_app(findings), &elem(&1, 0))
[:a, :b]
Groups findings by file, sorted by file name and by position within a file.
Returns a list of {file, findings} tuples rather than a map, so the order
is stable for rendering.
iex> alias ExQuality.Finding
iex> findings = [
...> %Finding{file: "a.ex", line: 9, message: "later"},
...> %Finding{file: "a.ex", line: 2, message: "earlier"}
...> ]
iex> [{"a.ex", [first, second]}] = Finding.group_by_file(findings)
iex> {first.message, second.message}
{"earlier", "later"}
Normalises a path reported by a tool into one relative to the run's root.
That root is the working directory, which is where mix quality is invoked
from, and which is the project root - the umbrella root, in an umbrella. It
is the same base ExQuality.Umbrella.app_for_path/2 matches against, so a
finding's file and its app cannot disagree about where it is.
Tools disagree about this. Credo reports a relative path, mix_audit reports
an absolute one, and a finding rendered as
/Users/someone/code/app/mix.lock is worse than one rendered as mix.lock
in every place a finding is used: it is longer to read, it does not match
what the reader would type, it differs between a laptop and CI for the same
problem, and it makes two reports of the same finding compare as different.
A path outside the project root is left absolute, because shortening it would be a lie about where the file is. Anything that is not a path - a module name a coverage finding falls back to - passes through unchanged.
iex> ExQuality.Finding.relative_path("mix.lock")
"mix.lock"
iex> ExQuality.Finding.relative_path(Path.join(File.cwd!(), "lib/user.ex"))
"lib/user.ex"
iex> ExQuality.Finding.relative_path(nil)
nil
Renders findings as a human-readable string, grouped by file.
Findings that name an umbrella app are grouped by app first, under a header, because "which app is broken" is the first question a reader of a ten-app umbrella has.
Returns an empty string for an empty list, so callers can test the result and fall back to raw tool output.
iex> alias ExQuality.Finding
iex> finding = %Finding{
...> file: "lib/user.ex", line: 42, column: 3,
...> severity: :warning, check: "readability",
...> message: "Modules should have a @moduledoc tag."
...> }
iex> Finding.render([finding])
"lib/user.ex\n 42:3 [warning] Modules should have a @moduledoc tag. (readability)\n"
Sorts findings by file, then line, then column.
Findings without a line or column sort before those that have one, so a file-level finding leads the file's list.
iex> alias ExQuality.Finding
iex> findings = [
...> %Finding{file: "b.ex", line: 1, message: "second"},
...> %Finding{file: "a.ex", line: 9, message: "first"}
...> ]
iex> Enum.map(Finding.sort(findings), & &1.message)
["first", "second"]