defmodule Mix.Tasks.Compile.Frontier do @moduledoc """ Verifies cross-module function calls according to Frontier boundaries. Add `:frontier` to your compilers in `mix.exs`: def project do [ compilers: [:frontier] ++ Mix.compilers(), # ... ] end """ use Mix.Task.Compiler alias Frontier.Rules alias Frontier.Store alias Frontier.Warning @recursive true @impl Mix.Task.Compiler def run(argv) do {opts, _rest, _errors} = OptionParser.parse(argv, strict: [warnings_as_errors: :boolean]) Store.init() Store.reset_initialized() Mix.Task.Compiler.after_compiler(:elixir, &after_elixir_compiler/1) Mix.Task.Compiler.after_compiler(:app, &after_app_compiler(&1, opts)) tracers = Code.get_compiler_option(:tracers) Code.put_compiler_option(:tracers, [Frontier.Tracer | tracers]) {:ok, []} end defp after_elixir_compiler(outcome) do tracers = Enum.reject(Code.get_compiler_option(:tracers), &(&1 == Frontier.Tracer)) Code.put_compiler_option(:tracers, tracers) outcome end defp after_app_compiler(outcome, opts) do with {status, diagnostics} when status in [:ok, :noop] <- outcome do reference_errors = check_references() validation_errors = Frontier.Validation.validate() |> Enum.map(fn error -> %Mix.Task.Compiler.Diagnostic{ compiler_name: "frontier", details: nil, file: nil, message: error.message, position: 0, severity: :warning } end) errors = reference_errors ++ validation_errors print_diagnostics(errors) {status(errors, opts), diagnostics ++ errors} end end defp check_references do Store.references() |> Enum.flat_map(&check_reference/1) |> Enum.uniq_by(&{&1.file, &1.position, &1.message}) |> Enum.sort_by(&{&1.file, &1.position}) end defp check_reference(reference) do case Rules.check(reference.from, reference.to) do :ok -> check_external_reference(reference) {:violation, _type, _details} = violation -> [to_diagnostic(violation, reference)] end end defp check_external_reference(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_diagnostic(violation, reference)] end else [] end end defp to_diagnostic(violation, reference) do %Mix.Task.Compiler.Diagnostic{ compiler_name: "frontier", details: nil, file: reference.file, message: Warning.format(violation), position: reference.line, severity: :warning } end defp print_diagnostics([]), do: :ok defp print_diagnostics(diagnostics) do Mix.shell().info("") Enum.each(diagnostics, fn diag -> pos = if is_integer(diag.position) and diag.position > 0, do: ":#{diag.position}", else: "" Mix.shell().info([ :bright, :yellow, "warning: ", :reset, diag.message, "\n #{diag.file}#{pos}\n" ]) end) end defp status([], _opts), do: :ok defp status([_ | _], opts) do if Keyword.get(opts, :warnings_as_errors, false), do: :error, else: :ok end end