ExQuality.Finding (ExQuality v0.8.0)

View Source

A 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 root
  • line - 1-based line number, or nil when the tool did not report one
  • column - 1-based column, when the tool reports one
  • app - umbrella app the file belongs to, when known
  • severity - :error, :warning or :info
  • check - the tool's rule identifier (a check module, a category, a code)
  • message - the human-readable problem description
  • raw - the tool output lines this finding was derived from

Summary

Functions

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

severity()

@type severity() :: :error | :warning | :info

t()

@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

group_by_app(findings)

@spec group_by_app([t()]) :: [{atom() | nil, [t()]}]

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]

group_by_file(findings)

@spec group_by_file([t()]) :: [{String.t(), [t()]}]

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"}

relative_path(path)

@spec relative_path(String.t() | nil) :: String.t() | nil

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

render(findings)

@spec render([t()]) :: String.t()

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"

sort(findings)

@spec sort([t()]) :: [t()]

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"]