defmodule Mix.Tasks.Frontier.Check do @shortdoc "Runs Frontier boundary checks on demand." @moduledoc """ Runs boundary checks without requiring `:frontier` in your compilers list. Forces a recompile with the tracer enabled, collects cross-module references, and reports any violations. $ mix frontier.check $ mix frontier.check --warnings-as-errors Use this when you don't want compile-time checking but still want to verify boundaries in CI or before a PR. ## Options * `--warnings-as-errors` - Exit with error status if any violations are found """ use Mix.Task alias Frontier.Rules alias Frontier.Store alias Frontier.Tracer alias Frontier.Warning @impl Mix.Task def run(argv) do {opts, _rest, _errors} = OptionParser.parse(argv, strict: [warnings_as_errors: :boolean]) Store.init() # Register tracer tracers = Code.get_compiler_option(:tracers) Code.put_compiler_option(:tracers, [Tracer | tracers]) # Force recompile to collect references Mix.Task.run("compile", ["--force"]) # Unregister tracer updated_tracers = Enum.reject(Code.get_compiler_option(:tracers), &(&1 == Tracer)) Code.put_compiler_option(:tracers, updated_tracers) # Load state from compiled modules Store.ensure_loaded() # Check references errors = check_references() validation_errors = check_validation() all_errors = errors ++ validation_errors print_results(all_errors) if all_errors != [] and Keyword.get(opts, :warnings_as_errors, false) do System.halt(1) end end defp check_references do Store.references() |> Enum.flat_map(&check_reference/1) |> Enum.uniq_by(&{&1.file, &1.line, &1.message}) |> Enum.sort_by(&{&1.file, &1.line}) end defp check_reference(reference) do case Rules.check(reference.from, reference.to) do :ok -> check_external(reference) {:violation, _type, _details} = violation -> [to_error(violation, reference)] end end defp check_external(reference) do caller_context = Frontier.Classifier.owning_context(reference.from) to_app = if caller_context, do: Application.get_application(reference.to) own_app = Keyword.fetch!(Mix.Project.config(), :app) if caller_context && to_app && to_app != own_app do case Rules.check_external(caller_context, to_app) do :ok -> [] {:violation, _type, _details} = violation -> [to_error(violation, reference)] end else [] end end defp check_validation do Frontier.Validation.validate() |> Enum.map(fn error -> %{file: nil, line: 0, message: error.message} end) end defp to_error(violation, reference) do %{ file: reference.file, line: reference.line, message: Warning.format(violation) } end defp print_results([]) do Mix.shell().info([:green, "No boundary violations found."]) end defp print_results(errors) do Mix.shell().info("") Enum.each(errors, fn error -> pos = if is_integer(error.line) and error.line > 0, do: ":#{error.line}", else: "" file = error.file || "" Mix.shell().info([ :bright, :yellow, "warning: ", :reset, error.message, "\n #{file}#{pos}\n" ]) end) count = length(errors) Mix.shell().info([ :red, "Found #{count} boundary violation#{if count > 1, do: "s", else: ""}." ]) end end