defmodule Reach.CLI.Commands.Check do @moduledoc """ Runs structural validation and change-safety checks. mix reach.check mix reach.check --arch mix reach.check --changed --base main mix reach.check --dead-code mix reach.check --smells mix reach.check --candidates ## Options * `--format` — output format: `text` or `json` * `--arch` — check `.reach.exs` architecture policy * `--changed` — report changed functions and configured test hints * `--base` — git base ref for `--changed` (default: auto-detect `main`, `master`, or upstream) * `--dead-code` — find unused pure expressions * `--smells` — find graph/effect/data-flow performance smells * `--strict` — fail when smell findings are present (or set `smells: [strict: true]`) * `--baseline` — ignore known findings from a Reach baseline file * `--write-baseline` — write current findings to a Reach baseline file * `--candidates` — emit advisory refactoring candidates * `--top` — limit candidate output for `--candidates` * `--path` — analyze an explicit source path instead of environment-derived Mix paths """ alias Reach.Check.Architecture alias Reach.Check.Architecture.Result, as: ArchitectureResult alias Reach.Check.Baseline alias Reach.Check.Candidates alias Reach.Check.Changed alias Reach.Check.Finding alias Reach.CLI.Commands.Check.DeadCode alias Reach.CLI.Commands.Check.Smells alias Reach.CLI.Project alias Reach.CLI.Render.Check, as: CheckRender alias Reach.Config @check_modes [:arch, :changed, :dead_code, :smells, :candidates] def run(opts, positional \\ []) do Project.reset_analysis_scope() opts = put_source_paths(opts) case selected_modes(opts) do [] -> run_default(opts) [mode] -> run_mode(mode, opts, positional) modes -> run_modes(modes, opts, positional) end end defp put_source_paths(opts) do cond do opts[:project] -> opts opts[:path] -> Keyword.put(opts, :paths, [opts[:path]]) true -> case Config.read() |> Config.normalize() |> then(& &1.checks.source_paths) do [] -> opts path when is_binary(path) -> Keyword.put(opts, :paths, [path]) paths when is_list(paths) -> maybe_put_path_list(opts, paths) _invalid -> opts end end end defp maybe_put_path_list(opts, paths) do if Enum.all?(paths, &is_binary/1), do: Keyword.put(opts, :paths, paths), else: opts end defp selected_modes(opts) do Enum.filter(@check_modes, &opts[&1]) end defp run_modes(modes, opts, positional) do if opts[:format] == "json" do Mix.raise("JSON output supports one reach.check mode at a time") end opts = maybe_put_check_context(opts, modes, positional) Enum.each(modes, &run_mode(&1, opts, positional)) end defp maybe_put_check_context(opts, modes, positional) do opts |> Keyword.put(:multi_check?, length(modes) > 1) |> maybe_put_shared_project(modes, positional) end defp maybe_put_shared_project(opts, modes, []) do if share_project?(opts, modes) do project_opts = [quiet: false, show_scope: true, retain_module_sdgs: :smells not in modes] ++ source_opts(opts) ++ plugin_opts(opts) project = Project.load(project_opts) if :smells in modes, do: :erlang.garbage_collect() Keyword.put(opts, :project, project) else opts end end defp maybe_put_shared_project(opts, _modes, _positional), do: opts defp share_project?(opts, modes) do is_nil(opts[:path]) and Enum.any?(modes, &(&1 in [:arch, :smells])) end defp run_mode(:arch, opts, _positional), do: run_arch(opts) defp run_mode(:changed, opts, _positional), do: run_changed(opts) defp run_mode(:dead_code, opts, positional), do: DeadCode.run(opts, positional, "reach.check") defp run_mode(:smells, opts, positional), do: Smells.run(opts, positional, "reach.check") defp run_mode(:candidates, opts, positional), do: run_candidates(opts, positional) defp run_default(opts) do if Config.read() != [] do run_arch(opts) else CheckRender.render_no_default() end end defp run_arch(opts) do config = Config.read!() result = case Architecture.config_violations(config) do [] -> project_opts = [quiet: opts[:format] == "json", show_scope: true, source_only: true] ++ source_opts(opts) project = opts[:project] || Project.load(project_opts) Architecture.run(project, config) violations -> %ArchitectureResult{status: "failed", violations: violations} end config = Config.normalize(config) finding_count = length(result.violations) findings = Enum.map(result.violations, &Finding.from_arch_violation/1) scope = Project.analysis_scope() if write_path = Baseline.write_path(opts) do Baseline.write(write_path, :arch, findings, scope) end baseline_path = Baseline.path(opts, config) validate_baseline_scope!(baseline_path, :arch, scope) {new_findings, baseline_findings} = Baseline.filter(findings, baseline_path) violations = filter_violations(result.violations, new_findings) result = %{ result | violations: violations, status: if(violations == [], do: "ok", else: "failed"), finding_count: finding_count, baseline_count: length(baseline_findings), analysis: scope } CheckRender.render_result(result, opts[:format], &CheckRender.render_arch_text/1) if result.violations != [] do Mix.raise("Architecture policy failed") end end defp validate_baseline_scope!(path, source, scope) do case Baseline.validate_scope(path, source, scope) do :ok -> :ok :legacy -> Mix.raise( "Baseline #{path} has no analysis scope metadata. Regenerate it with --write-baseline before reuse." ) {:error, message} -> Mix.raise(message) end end defp filter_violations(violations, findings) do allowed = MapSet.new(Enum.map(findings, & &1.fingerprint)) Enum.filter(violations, fn violation -> violation |> Finding.from_arch_violation() |> then(&MapSet.member?(allowed, &1.fingerprint)) end) end defp run_changed(opts) do config = Config.read() base = opts[:base] || Changed.default_base_ref() files = Changed.changed_files(base) ranges = Changed.changed_ranges(base) result = if map_size(ranges) == 0 do Changed.empty_result(base, config, files) else project = Project.load( [quiet: opts[:format] == "json", show_scope: true] ++ source_opts(opts) ++ plugin_opts(opts) ) Changed.run(project, config, base: base, files: files, changed_ranges: ranges) end CheckRender.render_result(result, opts[:format], &CheckRender.render_changed_text/1) end defp run_candidates(opts, positional) do project = load_candidates_project(opts, positional) config = Config.read() result = project |> Candidates.run(config, top: opts[:top] || 40) |> Map.put(:analysis, Project.analysis_scope()) CheckRender.render_result(result, opts[:format], &CheckRender.render_candidates_text/1) end defp plugin_opts(opts), do: Keyword.take(opts, [:plugins]) defp source_opts(opts), do: Keyword.take(opts, [:paths]) defp load_candidates_project(opts, positional) do path = opts[:path] || List.first(positional) cond do opts[:project] -> opts[:project] path -> Project.load( [paths: [path], quiet: opts[:format] == "json", show_scope: true] ++ plugin_opts(opts) ) opts[:paths] -> Project.load( [paths: opts[:paths], quiet: opts[:format] == "json", show_scope: true] ++ plugin_opts(opts) ) true -> Project.load([quiet: opts[:format] == "json", show_scope: true] ++ plugin_opts(opts)) end end end