lib/bylaw/credo/check/elixir/named_spec_params.ex

defmodule Bylaw.Credo.Check.Elixir.NamedSpecParams do
  @moduledoc """
  Requires named parameters in all `@spec` declarations.

  ## Examples

  Avoid:
  Positional-only types omit what each argument represents:

        @spec fetch(UUIDv7.t(), integer()) :: {:ok, Run.t()} | {:error, term()}
        @spec submit(UUIDv7.t(), UUIDv7.t(), UUIDv7.t(), UUIDv7.t(), list(map())) :: :ok
  Prefer:
  Give each parameter a name so the spec is self-documenting:

        @spec fetch(run_id :: UUIDv7.t(), limit :: integer()) :: {:ok, Run.t()} | {:error, term()}
        @spec submit(
                tenant_id :: UUIDv7.t(),
                workspace_id :: UUIDv7.t(),
                run_id :: UUIDv7.t(),
                message_id :: UUIDv7.t(),
                tool_results :: list(map())
              ) :: :ok

  ## Notes

  This check uses static AST analysis, so it favors clear source-level patterns over runtime behavior.

  ## Options

  Configure options in `.credo.exs` with the check tuple:

  ```elixir
  %{
    configs: [
      %{
        name: "default",
        checks: [
          {Bylaw.Credo.Check.Elixir.NamedSpecParams,
           [
             min_params: 2
           ]}
        ]
      }
    ]
  }
  ```

  - `:min_params` - Minimum number of parameters to trigger the check (default: 1).

  ## Usage

  Add this check to Credo's `checks:` list in `.credo.exs`:

  ```elixir
  %{
    configs: [
      %{
        name: "default",
        checks: [
          {Bylaw.Credo.Check.Elixir.NamedSpecParams, []}
        ]
      }
    ]
  }
  ```
  """

  use Credo.Check,
    base_priority: :normal,
    category: :readability,
    param_defaults: [min_params: 1],
    explanations: [
      check: @moduledoc,
      params: [
        min_params: "Minimum number of parameters to trigger the check (default: 1)."
      ]
    ]

  @doc false
  @impl Credo.Check
  def run(%Credo.SourceFile{} = source_file, params \\ []) do
    issue_meta = IssueMeta.for(source_file, params)
    min_params = Params.get(params, :min_params, __MODULE__)

    source_file
    |> Credo.SourceFile.ast()
    |> find_issues(issue_meta, min_params)
  end

  defp find_issues({:ok, ast}, issue_meta, min_params) do
    case Macro.prewalk(ast, [], &traverse(&1, &2, issue_meta, min_params)) do
      {_ast, issues} -> issues
    end
  end

  defp find_issues(ast, issue_meta, min_params) when is_tuple(ast) do
    case Macro.prewalk(ast, [], &traverse(&1, &2, issue_meta, min_params)) do
      {_ast, issues} -> issues
    end
  end

  defp find_issues(_error, _issue_meta, _min_params), do: []

  # @spec fun_name(args...) :: return_type
  defp traverse(
         {:@, _meta, [{:spec, _spec_meta, [spec_definition]}]} = node,
         issues,
         issue_meta,
         min_params
       ) do
    args = extract_args(spec_definition)

    if Enum.count(args) >= min_params and not all_named?(args) do
      line = spec_line(spec_definition)
      {node, [issue_for(issue_meta, line) | issues]}
    else
      {node, issues}
    end
  end

  defp traverse(node, issues, _issue_meta, _min_params), do: {node, issues}

  # @spec fun(args) :: ret when constraints
  defp extract_args(
         {:when, _when_meta,
          [{:"::", _op_meta, [{_fun_name, _fun_meta, args} | _rest]} | _when_clauses]}
       )
       when is_list(args) do
    args
  end

  # @spec fun(args) :: ret
  defp extract_args({:"::", _op_meta, [{_fun_name, _fun_meta, args} | _rest]})
       when is_list(args) do
    args
  end

  defp extract_args(_other), do: []

  defp all_named?(args) do
    Enum.all?(args, fn
      {:"::", _meta, _parts} -> true
      _other -> false
    end)
  end

  defp spec_line({:when, _when_meta, [{:"::", meta, _parts} | _clauses]}), do: meta[:line]
  defp spec_line({:"::", meta, _parts}), do: meta[:line]
  defp spec_line(_other), do: 0

  defp issue_for(issue_meta, line_no) do
    format_issue(
      issue_meta,
      message: "Spec parameters should use named types (e.g., `tenant_id :: UUIDv7.t()`).",
      trigger: "@spec",
      line_no: line_no
    )
  end
end