lib/foundry/context/module_discovery.ex

defmodule Foundry.Context.ModuleDiscovery do
  @moduledoc """
  Discovers all project modules by scanning the compiled BEAM files.

  This module extracts module discovery logic into a single source of truth,
  used by both GraphBuilder and other context-building tasks.
  """

  @spec all_project_modules(String.t(), String.t()) :: list(atom())
  def all_project_modules(project_root, project_name_string) do
    underscored = Macro.underscore(project_name_string)
    prefix = "Elixir." <> project_name_string <> "."

    # Try dev first, then fall back to test (for subprocess with MIX_ENV=test)
    ebin_path =
      ["dev", "test"]
      |> Enum.map(&Path.join([project_root, "_build", &1, "lib", underscored, "ebin"]))
      |> Enum.find(&File.dir?/1)

    case ebin_path do
      nil ->
        []

      path ->
        Code.append_path(path)
        Path.wildcard(Path.join(path, "*.beam"))
        |> Enum.map(&(&1 |> Path.basename(".beam") |> String.to_atom()))
        |> Enum.filter(&(Atom.to_string(&1) |> String.starts_with?(prefix)))
        |> Enum.filter(&Code.ensure_loaded?/1)
        |> Enum.filter(&is_project_module?/1)
    end
  end

  # Filter out generated modules, domains, and infrastructure
  # Keep user-defined resources, reactors, rules, blueprints, providers, jobs
  defp is_project_module?(module) do
    module_str = Atom.to_string(module)
    clean_name = String.replace(module_str, ~r/^Elixir\./, "")
    part_count = clean_name |> String.split(".") |> Enum.count()

    # Blacklist: modules we definitely don't want
    should_exclude =
      # CLDR-generated modules
      String.contains?(module_str, ".Cldr") or
        # Migration-related and generated version modules
        String.contains?(module_str, ["Migrations", ".Version"]) or
        # Infrastructure modules (but keep .Rules.*)
        (String.contains?(module_str, [".Application", ".Repo", ".Secrets"]) and
           not String.contains?(module_str, [".Rules."])) or
        # Behaviour definitions and base behavior modules (Adapter, Rule)
        String.contains?(module_str, "Adapter") and not String.contains?(module_str, ".Adapters.") or
        module_str == "Elixir.IgamingRef.Rule" or
        # Domain containers have exactly 2 parts (Project.Domain)
        part_count == 2

    not should_exclude
  end
end