lib/bylaw/credo/check/ecto/own_context_for_schema.ex

defmodule Bylaw.Credo.Check.Ecto.OwnContextForSchema do
  @moduledoc """
  Each Ecto schema using a configured schema wrapper should live under its own dedicated context module.

  ## Examples

  Configure the schema wrapper modules that identify application schemas:

  ```elixir
  {Bylaw.Credo.Check.Ecto.OwnContextForSchema,
   [
     schema_modules: [MyApp.Schema]
   ]}
  ```


  Avoid:
  `ToolCall` is nested under the `Runs` context:

        defmodule MyApp.Runs.ToolCall do
          use MyApp.Schema
        end

  Prefer:
  `ToolCall` has its own context:

        defmodule MyApp.ToolCalls.ToolCall do
          use MyApp.Schema
        end

  ## Notes

  Keeping one schema per context ensures that context modules stay small
  and focused. When a schema is nested under another schema's context
  (e.g. `MyApp.Runs.ToolCall`), the context tends to accumulate
  unrelated responsibilities.

  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.Ecto.OwnContextForSchema,
           [
             schema_modules: [MyApp.Schema],
             excluded_modules: ["MyApp.Legacy.LegacySchema"]
           ]}
        ]
      }
    ]
  }
  ```

  - `:schema_modules` - Schema wrapper modules that identify application schemas to check.
  - `:excluded_modules` - List of fully qualified module names (as strings) to exclude from this check.

  ## Usage

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

  ```elixir
  %{
    configs: [
      %{
        name: "default",
        checks: [
          {Bylaw.Credo.Check.Ecto.OwnContextForSchema,
           [
             schema_modules: [MyApp.Schema]
           ]}
        ]
      }
    ]
  }
  ```
  """

  use Credo.Check,
    base_priority: :higher,
    category: :design,
    param_defaults: [schema_modules: [], excluded_modules: []],
    explanations: [
      check: @moduledoc,
      params: [
        schema_modules: "Schema wrapper modules that identify application schemas to check.",
        excluded_modules:
          "List of fully qualified module names (as strings) to exclude from this check."
      ]
    ]

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

    source_file
    |> Credo.SourceFile.ast()
    |> collect_issues(issue_meta, schema_modules, excluded_modules)
  end

  defp collect_issues(ast, issue_meta, schema_modules, excluded_modules) do
    schema_module_names = normalized_schema_module_names(schema_modules)

    ast
    |> Macro.prewalk([], &traverse(&1, &2, issue_meta, schema_module_names, excluded_modules))
    |> elem(1)
  end

  defp traverse(
         {:defmodule, meta, [{:__aliases__, _meta, module_parts}, [do: body]]} = ast,
         issues,
         issue_meta,
         schema_module_names,
         excluded_modules
       ) do
    module_name = Enum.map_join(module_parts, ".", &Atom.to_string/1)

    if uses_schema_module?(body, schema_module_names) and module_name not in excluded_modules do
      case check_context_match(module_parts, module_name) do
        :ok ->
          {ast, issues}

        {:error, parent_name, schema_name} ->
          issue =
            format_issue(
              issue_meta,
              message:
                "`#{schema_name}` should not live under `#{parent_name}`. " <>
                  "Move it to its own context (e.g. `#{suggest_context(schema_name)}.#{schema_name}`).",
              trigger: module_name,
              line_no: meta[:line] || 0
            )

          {ast, [issue | issues]}
      end
    else
      {ast, issues}
    end
  end

  defp traverse(ast, issues, _issue_meta, _schema_module_names, _excluded_modules),
    do: {ast, issues}

  defp normalized_schema_module_names(schema_modules) do
    Enum.map(schema_modules, fn
      module when is_atom(module) -> Module.split(module) |> Enum.join(".")
      module when is_binary(module) -> module
    end)
  end

  defp uses_schema_module?({:__block__, _meta, children}, schema_module_names) do
    Enum.any?(children, &uses_schema_module?(&1, schema_module_names))
  end

  defp uses_schema_module?(
         {:use, _meta, [{:__aliases__, _aliases_meta, module_parts} | _rest]},
         schema_module_names
       ) do
    module_name = Enum.map_join(module_parts, ".", &Atom.to_string/1)

    module_name in schema_module_names
  end

  defp uses_schema_module?(_other, _schema_module_names), do: false

  defp check_context_match(module_parts, _module_name) when length(module_parts) < 3, do: :ok

  defp check_context_match(module_parts, _module_name) do
    schema_name =
      module_parts
      |> List.last()
      |> Atom.to_string()

    parent_name =
      module_parts
      |> Enum.at(-2)
      |> Atom.to_string()

    if context_matches_schema?(parent_name, schema_name) do
      :ok
    else
      {:error, parent_name, schema_name}
    end
  end

  # credo:disable-for-next-line Bylaw.Credo.Check.Elixir.NoPassthroughWrapper
  defp context_matches_schema?(parent, schema) do
    # The parent context should be a pluralized/collection form of the schema name.
    # We check if the parent starts with the schema name (e.g. "Agents" starts with "Agent",
    # "ToolCalls" starts with "ToolCall", "Accounts" starts with "Account").
    String.starts_with?(parent, schema)
  end

  defp suggest_context(schema_name) do
    schema_name <> "s"
  end
end