lib/checks/no_runtime_access.ex

defmodule TallariumCredo.Checks.NoRuntimeAccess do
  @moduledoc """
  Disallows runtime access to the specified modules.
  """

  use Credo.Check, base_priority: :high, category: :warning

  import Destructure

  @explanation [
    check: @moduledoc,
    params: [
      modules: "Modules to disallow access to."
    ]
  ]

  @default_params [
    modules: []
  ]

  @doc false
  def run(source_file, params \\ []) do
    issue_meta = IssueMeta.for(source_file, params)

    disallowed_modules =
      params
      |> Params.get(:modules, __MODULE__)
      |> Enum.map(&module_to_path/1)

    initial_state =
      d(%{
        issue_meta,
        disallowed_modules,
        issues: [],
        in_module_attribute?: false,
        in_dot_access?: false
      })

    d(%{issues}) = Credo.Code.prewalk(source_file, &traverse/2, initial_state)

    issues
  end

  defp traverse({:@, _meta, _arguments} = ast, state) do
    enter_with_flag(ast, state, :in_module_attribute?)
  end

  defp traverse({:., _meta, _arguments} = ast, state) do
    enter_with_flag(ast, state, :in_dot_access?)
  end

  defp traverse(
         {:__aliases__, meta, module} = ast,
         d(%{issue_meta, disallowed_modules, issues, in_module_attribute?, in_dot_access?}) =
           state
       ) do
    module_path = if is_list(module), do: module, else: [module]

    # Allow access to the fields of the specified modules, in the expression
    # for a module attribute. This ensures the module itself can't be accessed
    # at runtime.
    safe_access? = in_module_attribute? and in_dot_access?

    new_issues =
      if safe_access? do
        []
      else
        for disallowed_module <- disallowed_modules,
            List.starts_with?(module_path, disallowed_module) do
          issue_for(issue_meta, meta[:line], module_path_to_str(disallowed_module))
        end
      end

    {ast, %{state | issues: new_issues ++ issues}}
  end

  defp traverse(ast, state) do
    {ast, state}
  end

  defp enter_with_flag(ast, state, flag) when is_atom(flag) do
    if state[flag] == false do
      # Do a sub-walk with the flag on
      new_state = Credo.Code.prewalk(ast, &traverse/2, %{state | flag => true})
      # Tell the outer walk not to go down this subtree
      {nil, %{new_state | flag => false}}
    else
      # If the flag is on already, we are already in the sub-walk and should
      # continue as normal
      {ast, state}
    end
  end

  defp issue_for(issue_meta, line_no, trigger) do
    format_issue(issue_meta,
      message: "Runtime access to #{trigger} is not allowed. Use a module attribute.",
      line_no: line_no,
      trigger: trigger
    )
  end

  #

  defp module_to_path(module) when is_atom(module) do
    module
    |> Atom.to_string()
    |> String.split(".")
    |> case do
      ["Elixir" | rest] -> rest
      path -> path
    end
    |> Enum.map(&String.to_atom/1)
  end

  defp module_path_to_str(module) when is_list(module) do
    Enum.join(module, ".")
  end
end