lib/foundry/lint_rules/ash_ai_step_rule.ex

defmodule Foundry.LintRules.AshAiStepRule do
  @moduledoc """
  Agent steps must declare confidence thresholds, tools, and telemetry (INV-014..017).

  Rule IDs:
  - `:agent_step_missing_confidence_threshold` — decision/scorer step without confidence_threshold
  - `:agent_step_missing_tools` — agent step without explicit tools declaration
  - `:agent_step_missing_telemetry` — agent step without telemetry prefix

  These rules check Reactor modules that contain `:agent` steps. The step
  metadata is extracted from the Reactor DSL and validated against the
  invariants.
  """

  @behaviour SparkLint.Rule

  def check(module, ctx) do
    sensitive = ctx.metadata[:sensitive_modules] || []
    is_sensitive = module in sensitive

    violations =
      module
      |> extract_agent_steps()
      |> Enum.flat_map(fn step ->
        validate_agent_step(step, is_sensitive)
      end)

    {:ok, violations}
  rescue
    _ -> {:ok, []}
  end

  defp extract_agent_steps(module) do
    # Check if this is a Reactor module by looking for the Reactor DSL
    if reactor_module?(module) do
      case get_reactor_steps(module) do
        steps when is_list(steps) ->
          Enum.filter(steps, fn step ->
            step.step_kind == :agent
          end)

        _ ->
          []
      end
    else
      []
    end
  rescue
    _ -> []
  end

  defp reactor_module?(module) do
    :ok == Ash.Resource.Info.resource?(module) ||
      function_exported?(module, :reactor, 0)
  rescue
    _ -> false
  end

  defp get_reactor_steps(module) do
    # Try to extract steps from the Reactor DSL
    if function_exported?(module, :steps, 0) do
      module.steps()
    else
      []
    end
  rescue
    _ -> []
  end

  defp validate_agent_step(step, _is_sensitive) do
    violations = []

    # INV-014: decision/scorer steps must have confidence_threshold
    violations =
      if is_decision_or_scorer?(step) and is_nil(step.confidence_threshold) do
        [
          %SparkLint.Violation{
            rule: :agent_step_missing_confidence_threshold,
            module: step.__module__,
            message:
              "Agent step #{inspect(step.name)} in #{inspect(step.__module__)} is a #{step_type_label(step)} but does not declare a confidence_threshold. Add `confidence_threshold: 0.8` (or appropriate value) to the step.",
            severity: :error
          }
          | violations
        ]
      else
        violations
      end

    # INV-016: agent steps must declare tool access explicitly
    violations =
      if empty_step_tools?(step) do
        [
          %SparkLint.Violation{
            rule: :agent_step_missing_tools,
            module: step.__module__,
            message:
              "Agent step #{inspect(step.name)} in #{inspect(step.__module__)} does not declare tools. Add `tools: [:action_name, ...]` to the step definition.",
            severity: :error
          }
          | violations
        ]
      else
        violations
      end

    # INV-017: agent steps must emit telemetry
    violations =
      if empty_telemetry_prefix?(step) do
        [
          %SparkLint.Violation{
            rule: :agent_step_missing_telemetry,
            module: step.__module__,
            message:
              "Agent step #{inspect(step.name)} in #{inspect(step.__module__)} does not declare telemetry_prefix. Add `telemetry_prefix: [:app, :domain, :reactor, :step]`.",
            severity: :error
          }
          | violations
        ]
      else
        violations
      end

    violations
  end

  defp is_decision_or_scorer?(step) do
    step.type in [:decision, :scorer, "decision", "scorer"]
  end

  defp step_type_label(step) do
    case step.type do
      :decision -> "decision"
      "decision" -> "decision"
      :scorer -> "scorer"
      "scorer" -> "scorer"
      other -> inspect(other)
    end
  end

  defp empty_step_tools?(step) do
    is_nil(step.step_tools) or step.step_tools == []
  end

  defp empty_telemetry_prefix?(step) do
    is_nil(step.step_telemetry_prefix) or step.step_telemetry_prefix == []
  end
end