Current section
Files
Jump to
Current section
Files
lib/reach/cli/render/check.ex
defmodule Reach.CLI.Render.Check do
@moduledoc false
alias Reach.CLI.Format
alias Reach.CLI.Requirements
@text_limit 30
def render_no_default do
IO.puts("No default Reach checks configured.")
IO.puts("Use --arch, --changed, --dead-code, --smells, or --candidates.")
end
def render_result(result, "json", _text_fun) do
Requirements.json!()
IO.puts(Jason.encode!(json_envelope(result), pretty: true))
end
def render_result(result, _format, text_fun), do: text_fun.(result)
def render_candidates_text(%{candidates: []}) do
IO.puts(Format.header("Refactoring Candidates"))
IO.puts(" " <> Format.empty("no refactoring candidates"))
end
def render_candidates_text(%{candidates: candidates, note: note}) do
IO.puts(Format.header("Refactoring Candidates (#{length(candidates)})"))
IO.puts(Format.faint(note))
IO.puts("")
Enum.each(candidates, fn candidate ->
IO.puts(
" #{Format.bright(candidate.id)} #{Format.yellow(Format.humanize(candidate.kind))}: #{candidate.target}"
)
IO.puts(
" benefit=#{candidate.benefit} risk=#{Format.risk(candidate.risk)} confidence=#{Format.risk(Map.get(candidate, :confidence, :unknown))}"
)
if Map.get(candidate, :file) do
IO.puts(" #{Format.loc(candidate.file, candidate.line)}")
end
IO.puts(" evidence=#{Format.humanized_join(candidate.evidence)}")
render_representative_calls(candidate)
IO.puts(" suggestion=#{candidate.suggestion}")
IO.puts("")
end)
end
def render_arch_text(%{violations: []}) do
IO.puts(Format.header("Architecture Policy"))
IO.puts(" #{Format.green("OK")}")
end
def render_arch_text(%{violations: violations}) do
IO.puts(Format.header("Architecture Policy"))
IO.puts(" #{Format.red("#{length(violations)} violation(s)")}")
Enum.each(violations, fn
%{type: type} = violation when type in [:config_error, "config_error"] ->
IO.puts(" config #{violation.key}: #{violation.message}")
%{type: type} = violation when type in [:forbidden_module, "forbidden_module"] ->
IO.puts(
" #{Format.loc(violation.file, violation.line)} #{violation.module} (#{violation.rule})"
)
%{type: type} = violation when type in [:forbidden_file, "forbidden_file"] ->
IO.puts(" #{Format.path(violation.file)} (#{violation.rule})")
%{type: type} = violation when type in [:forbidden_dependency, "forbidden_dependency"] ->
IO.puts(
" #{Format.loc(violation.file, violation.line)} #{violation.caller_layer} -> #{violation.callee_layer} " <>
"#{violation.call}"
)
%{type: type} = violation when type in [:layer_cycle, "layer_cycle"] ->
IO.puts(" layer cycle: #{Enum.join(violation.layers, " -> ")}")
%{type: type} = violation when type in [:forbidden_call, "forbidden_call"] ->
IO.puts(
" #{Format.loc(violation.file, violation.line)} #{violation.caller_module} calls #{violation.call} (#{violation.rule})"
)
%{type: type} = violation when type in [:effect_policy, "effect_policy"] ->
IO.puts([
" #{Format.loc(violation.file, violation.line)} #{violation.module}.#{violation.function} disallowed effects: ",
Format.effects_join(violation.disallowed_effects)
])
%{type: type} = violation
when type in [
:public_api_boundary,
:internal_boundary,
"public_api_boundary",
"internal_boundary"
] ->
IO.puts(
" #{Format.loc(violation.file, violation.line)} #{violation.caller_module} -> #{violation.callee_module} #{violation.call} (#{violation.rule})"
)
end)
end
def render_changed_text(result) do
IO.puts(Format.header("Changed Code"))
IO.puts(" base=#{result.base} risk=#{risk_label(result.risk)}")
if result.risk_reasons != [] do
IO.puts(" reasons=#{Enum.join(result.risk_reasons, ", ")}")
end
[]
|> add_omitted(
render_limited_section("Changed files", result.changed_files, &IO.puts(" #{&1}"))
)
|> add_omitted(
render_limited_section("Changed functions", result.changed_functions, fn function ->
IO.puts(
" #{Format.bright(function.id)} #{Format.loc(function.file, function.line)} risk=#{risk_label(function.risk)} callers=#{function.direct_caller_count}/#{function.transitive_caller_count} branches=#{function.branch_count} effects=#{Format.effects_join(function.effects)}"
)
render_clone_siblings(function.clone_siblings)
end)
)
|> add_omitted(
render_limited_section("Public API touched", result.public_api_changes, fn function ->
IO.puts(" #{Format.bright(function.id)} #{Format.loc(function.file, function.line)}")
end)
)
|> add_omitted(
render_limited_section(
"Suggested tests",
result.suggested_tests,
&IO.puts(" mix test #{&1}")
)
)
|> render_omitted_summary()
end
defp render_clone_siblings([]), do: :ok
defp render_clone_siblings(nil), do: :ok
defp render_clone_siblings(siblings) do
IO.puts(" #{Format.faint("similar cloned functions may need matching updates:")}")
siblings
|> Enum.take(3)
|> Enum.each(fn sibling ->
IO.puts(" #{sibling.id} #{Format.loc(sibling.file, sibling.line)}")
end)
end
defp render_representative_calls(%{representative_calls: calls})
when is_list(calls) and calls != [] do
IO.puts(" representative calls:")
Enum.each(calls, fn call ->
IO.puts(" #{Format.loc(call.file, call.line)} #{call.caller_module} -> #{call.call}")
end)
end
defp render_representative_calls(_candidate), do: :ok
defp render_limited_section(_title, [], _render_fun), do: nil
defp render_limited_section(title, items, render_fun) do
item_count = length(items)
IO.puts("\n#{Format.section("#{title} (#{item_count})")}")
items
|> Enum.take(@text_limit)
|> Enum.each(render_fun)
omitted = item_count - @text_limit
if omitted > 0, do: {title, omitted}
end
defp add_omitted(omitted, nil), do: omitted
defp add_omitted(omitted, entry), do: [entry | omitted]
defp render_omitted_summary([]), do: :ok
defp render_omitted_summary(omitted) do
summary =
omitted
|> Enum.reverse()
|> Enum.map(fn {title, count} -> [title, ": ", to_string(count)] end)
|> Enum.intersperse(", ")
|> IO.iodata_to_binary()
IO.puts(
"\n" <>
Format.omitted("Output truncated (#{summary}). Use --format json for complete output.")
)
end
defp risk_label(risk), do: Format.risk(risk)
defp json_envelope(result) do
%Reach.CLI.JSONEnvelope{
command: Map.get(result, :command, "reach.check"),
data: Map.delete(result, :command)
}
end
end