lib/foundry/spark_meta/side_effects.ex

defmodule Foundry.SparkMeta.SideEffects do
  @moduledoc false

  @behaviour SparkMeta.Analyzer

  alias SparkMeta.Analysis
  alias Foundry.SparkMeta.{Helpers, SideEffectEntry}

  @impl SparkMeta.Analyzer
  def analyze(context, %Analysis{} = analysis) do
    classifier = Map.get(analysis.facts, :foundry_classifier, %{})
    reactor = Map.get(analysis.facts, :foundry_reactor, %{steps: []})

    side_effects =
      cond do
        classifier[:type] == :resource ->
          extract_action_side_effects(context.module)

        classifier[:type] in [:reactor, :transfer] ->
          reactor.steps |> Enum.flat_map(& &1.side_effects) |> Enum.uniq()

        classifier[:type] == :trigger ->
          context
          |> Foundry.SparkMeta.ReactorFacts.module_source_context()
          |> then(&extract_module_side_effects(&1.module_source, classifier[:trigger_kind]))

        true ->
          []
      end

    {:ok, Analysis.put_fact(analysis, :foundry_side_effects, side_effects)}
  end

  def extract_side_effects_from_step(nil, _step_name), do: []

  def extract_side_effects_from_step(snippet, step_name) do
    annotated =
      Regex.scan(~r/#\s*@side_effect\s+([^:\n]+):\s*([^,\n]+)(.*)/, snippet)
      |> Enum.map(fn [_, type_str, name_str, rest] ->
        opts = parse_side_effect_opts(rest)

        %SideEffectEntry{
          type: String.trim(type_str) |> String.to_atom(),
          name: String.trim(name_str),
          declared_on: :step,
          step_name: to_string(step_name),
          queue: Map.get(opts, "queue"),
          idempotent: Map.get(opts, "idempotent") == "true",
          idempotency_key_from: Map.get(opts, "key_from") |> parse_list(),
          declared: true,
          epistemic: "VERIFIED"
        }
      end)

    inferred = []

    inferred =
      if String.contains?(snippet, ["Oban.insert", "Oban.insert!"]) and
           not has_side_effect_type?(annotated, :oban_emit) do
        oban_job_name =
          case Regex.run(~r/Oban\.insert[!]?\(\s*([A-Z][A-Za-z0-9.]+)\.new/, snippet) do
            [_, module] -> module
            _ -> "unnamed_oban_job"
          end

        inferred ++
          [
            %SideEffectEntry{
              type: :oban_emit,
              name: oban_job_name,
              declared_on: :step,
              step_name: to_string(step_name),
              declared: false,
              epistemic: "INFERRED"
            }
          ]
      else
        inferred
      end

    inferred =
      if String.contains?(snippet, ["Req.", "Finch.", "HTTPoison", "Tesla"]) and
           not has_side_effect_type?(annotated, :external_http) do
        inferred ++
          [
            %SideEffectEntry{
              type: :external_http,
              name: "external_call",
              declared_on: :step,
              step_name: to_string(step_name),
              declared: false,
              epistemic: "INFERRED"
            }
          ]
      else
        inferred
      end

    annotated ++ inferred
  end

  defp extract_action_side_effects(module) do
    try do
      Ash.Resource.Info.actions(module)
      |> Enum.flat_map(fn action ->
        notifiers = Map.get(action, :notifiers, [])
        changes = Map.get(action, :changes, [])

        notifier_entries =
          Enum.map(notifiers, fn notifier ->
            %SideEffectEntry{
              type: :ash_notifier,
              name: Helpers.format_module_fqn(notifier),
              declared_on: :resource_action,
              action: to_string(action.name),
              declared: true,
              epistemic: "VERIFIED"
            }
          end)

        change_entries =
          changes
          |> Enum.filter(fn
            %{change: {change_mod, _opts}} -> trigger_change?(change_mod)
            %{change: change_mod} -> trigger_change?(change_mod)
            _ -> false
          end)
          |> Enum.map(fn
            %{change: {change_mod, _opts}} -> change_mod
            %{change: change_mod} -> change_mod
          end)
          |> Enum.map(fn change_mod ->
            %SideEffectEntry{
              type: :ash_change,
              name: Helpers.format_module_fqn(change_mod),
              declared_on: :resource_action,
              action: to_string(action.name),
              declared: true,
              epistemic: "VERIFIED"
            }
          end)

        notifier_entries ++ change_entries
      end)
    rescue
      _ -> []
    end
  end

  defp trigger_change?(module) do
    module |> to_string() |> String.contains?([".Changes.", ".Notifiers."])
  end

  defp has_side_effect_type?(entries, type), do: Enum.any?(entries, &(&1.type == type))

  defp extract_module_side_effects(nil, _trigger_kind), do: []

  defp extract_module_side_effects(module_source, trigger_kind) do
    annotated =
      Regex.scan(~r/#\s*@side_effect\s+([^:\n]+):\s*([^,\n]+)(.*)/, module_source)
      |> Enum.map(fn [_, type_str, name_str, rest] ->
        opts = parse_side_effect_opts(rest)

        %SideEffectEntry{
          type: String.trim(type_str) |> String.to_atom(),
          name: String.trim(name_str),
          declared_on: :module,
          trigger: trigger_kind && to_string(trigger_kind),
          queue: Map.get(opts, "queue"),
          idempotent: Map.get(opts, "idempotent") == "true",
          idempotency_key_from: Map.get(opts, "key_from") |> parse_list(),
          declared: true,
          epistemic: "VERIFIED"
        }
      end)

    inferred_oban =
      case Regex.run(~r/Oban\.insert[!]?\(\s*([A-Z][A-Za-z0-9.]+)\.new/, module_source) do
        [_, module] ->
          if has_side_effect_type?(annotated, :oban_emit) do
            []
          else
            [
              %SideEffectEntry{
                type: :oban_emit,
                name: module,
                declared_on: :module,
                trigger: trigger_kind && to_string(trigger_kind),
                declared: false,
                epistemic: "INFERRED"
              }
            ]
          end

        _ ->
          []
      end

    inferred_http =
      if String.contains?(module_source, ["Req.", "Finch.", "HTTPoison", "Tesla"]) and
           not has_side_effect_type?(annotated, :external_http) do
        [
          %SideEffectEntry{
            type: :external_http,
            name: "external_call",
            declared_on: :module,
            trigger: trigger_kind && to_string(trigger_kind),
            declared: false,
            epistemic: "INFERRED"
          }
        ]
      else
        []
      end

    annotated ++ inferred_oban ++ inferred_http
  end

  defp parse_side_effect_opts(rest) do
    Regex.scan(~r/,\s*([^:\s]+):\s*([^,\s]+)/, rest)
    |> Enum.into(%{}, fn [_, key, value] -> {key, value} end)
  end

  defp parse_list(nil), do: []

  defp parse_list(str) do
    str
    |> String.trim_leading("[")
    |> String.trim_trailing("]")
    |> String.split(",")
    |> Enum.map(&String.trim/1)
    |> Enum.map(&String.trim_leading(&1, ":"))
    |> Enum.reject(&(&1 == ""))
  end
end