Skip to main content

lib/mix/tasks/bloccs.lint.ex

defmodule Mix.Tasks.Bloccs.Lint do
  @shortdoc "Transitive capability lint of every node in the project"

  @moduledoc """
      mix bloccs.lint

  Compiles the project, then for every bloccs node module (one exporting
  `__bloccs_manifest__/0`) walks its `pure_core` / `effect_shell` **and the
  same-application helpers they call, transitively**, applying the capability
  policy to every reachable call (`Bloccs.Lint.Transitive`).

  The compile-time check in `use Bloccs.Node` only sees a node's own body and
  permits calls into same-app helpers without traversing them. This task closes
  that gap: it is what makes "the declared-effect facade is the only path to the
  world" hold through helper indirection, not just for directly-written calls.
  Run it in CI / `mix check`. Exits non-zero if any node has a transitive
  violation.

  Requires modules compiled with debug info (the default for `dev`/`test`). A
  helper whose BEAM has no abstract code is reported as unanalyzable rather than
  assumed safe.

  Options:

    * `--app APP` — limit to modules of the given OTP application (defaults to
      the current Mix project's `:app`).
  """

  use Mix.Task

  alias Bloccs.Lint.Transitive

  @impl Mix.Task
  def run(argv) do
    {opts, _, _} = OptionParser.parse(argv, strict: [app: :string])
    Mix.Task.run("compile")
    {:ok, _} = Application.ensure_all_started(:bloccs)

    app =
      case opts[:app] do
        nil -> Mix.Project.config()[:app]
        name -> String.to_atom(name)
      end

    nodes = node_modules(app)

    if nodes == [] do
      Mix.shell().info("bloccs.lint: no node modules found in #{inspect(app)}.")
    else
      results = Enum.map(nodes, fn mod -> {mod, Transitive.lint(mod)} end)
      report(results)
    end
  end

  defp node_modules(app) do
    _ = Application.load(app)

    case :application.get_key(app, :modules) do
      {:ok, mods} -> Enum.filter(mods, &node_module?/1)
      _ -> []
    end
  end

  defp node_module?(mod) do
    Code.ensure_loaded?(mod) and function_exported?(mod, :__bloccs_manifest__, 0)
  end

  defp report(results) do
    flagged = Enum.reject(results, fn {_mod, vs} -> vs == [] end)
    total = results |> Enum.map(fn {_mod, vs} -> length(vs) end) |> Enum.sum()

    if flagged == [] do
      Mix.shell().info(
        "bloccs.lint: #{length(results)} node(s) clean — every reachable call stays inside the declared capabilities."
      )
    else
      Enum.each(flagged, fn {mod, vs} ->
        Mix.shell().error("\n#{inspect(mod)}:")

        Enum.each(vs, fn v ->
          Mix.shell().error("  [#{v.kind}] #{v.at}\n      #{v.reason}")
        end)
      end)

      Mix.raise(
        "bloccs.lint: #{total} transitive capability violation(s) across #{length(flagged)} node(s). " <>
          "Route the call through a declared effect, or record an exception in the node's [lint] section."
      )
    end
  end
end