defmodule Foundry.Context.Introspector do
@moduledoc """
Core Spark DSL introspection logic for `mix foundry.context` and
`mix foundry.context.all`.
Reads compiled modules via `Spark.Dsl.Extension` introspection and maps
them to `Foundry.Context.ModuleContext` structs. All reads are against
the live compiled beam — never against source text.
## Module type detection
Detection order (first match wins):
1. Implements `AshAuthentication` subject → `:resource` (auth)
2. Uses `Ash.Resource` → `:resource`
3. Uses `Reactor` with Transfer DSL → `:transfer`
4. Uses `Reactor` → checked for Oban worker pattern → `:oban_job`
5. Uses Foundry `Rule` DSL → `:rule`
6. Uses Foundry `Blueprint` DSL → `:blueprint`
7. Uses `Phoenix.LiveView` + LiveResource → `:live_page`
8. Implements provider adapter behaviour → `:adapter`
## Caller responsibility
The caller (Mix task) is responsible for ensuring the target project is
compiled and its modules are loaded before calling into this module.
`Introspector` never calls `Mix.Task.run("compile")` itself.
## Sensitive detection
A module is marked `sensitive: true` when its string name appears in
`manifest.sensitive_resources`, or when it is an `ash_authentication`
User/Token resource.
"""
alias Foundry.Context.ModuleContext
alias Foundry.PageMetadata
@type opts :: [
manifest_path: String.t(),
project_root: String.t()
]
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
@doc """
Build a `ModuleContext` for a single module.
Returns `{:ok, %ModuleContext{}}` or `{:error, reason}`.
"""
@spec build(module(), opts()) :: {:ok, ModuleContext.t()} | {:error, term()}
def build(mod, opts \\ []) do
project_root = Keyword.get(opts, :project_root, File.cwd!())
manifest = load_manifest(project_root)
sensitive_set = sensitive_set(manifest)
{:ok, build_context(mod, sensitive_set, project_root, %{})}
rescue
e -> {:error, Exception.message(e)}
end
@doc """
Build `ModuleContext` structs for all Foundry-relevant modules in the
compiled project, grouped by domain name string.
Returns `%{domain_name => [ModuleContext.t()]}`.
"""
@spec build_all(opts()) :: %{String.t() => [ModuleContext.t()]}
def build_all(opts \\ []) do
project_root = Keyword.get(opts, :project_root, File.cwd!())
manifest = load_manifest(project_root)
sensitive_set = sensitive_set(manifest)
excluded = excluded_set(manifest)
app_name = Mix.Project.config()[:app]
page_routes_map =
case Foundry.Context.RouterIntrospector.find_router(app_name, project_root) do
nil ->
%{}
router ->
try do
Foundry.Context.RouterIntrospector.liveview_routes(router)
|> Map.new(fn route -> {route.module, route} end)
rescue
_ -> %{}
end
end
# Discover page modules from router, or from module path pattern
page_modules =
case Map.keys(page_routes_map) do
[] ->
# Fallback: Find modules in Web.Live pattern
all_modules()
|> Enum.filter(fn mod ->
mod_str = to_string(mod)
(String.contains?(mod_str, ".Live.") or String.ends_with?(mod_str, "Live")) and
page_module?(mod)
end)
routes ->
routes
end
all_modules()
|> Kernel.++(page_modules)
|> Enum.uniq()
|> Enum.reject(&MapSet.member?(excluded, to_string(&1)))
|> Enum.filter(&foundry_relevant?/1)
|> Enum.map(&build_context(&1, sensitive_set, project_root, page_routes_map))
|> Enum.group_by(& &1.domain)
end
# ---------------------------------------------------------------------------
# Module discovery
# ---------------------------------------------------------------------------
defp all_modules do
# Load all beam modules from the project's _build directory.
# We restrict to modules whose beam files live under _build/dev/lib/<app>/
# to avoid introspecting dependencies.
app_name = Mix.Project.config()[:app]
beam_dir = Mix.Project.compile_path()
beam_dir
|> File.ls!()
|> Enum.filter(&String.ends_with?(&1, ".beam"))
|> Enum.map(fn filename ->
filename
|> String.replace_suffix(".beam", "")
|> String.to_atom()
end)
|> Enum.filter(fn mod ->
# Ensure the module is loadable and belongs to this app
case Code.ensure_loaded(mod) do
{:module, ^mod} -> module_app(mod) == app_name
_ -> false
end
end)
end
defp module_app(mod) do
case :application.get_application(mod) do
{:ok, app} -> app
_ -> nil
end
end
defp foundry_relevant?(mod) do
ash_resource?(mod) or
reactor_module?(mod) or
rule_module?(mod) or
blueprint_module?(mod) or
live_page_module?(mod) or
page_module?(mod) or
adapter_module?(mod)
end
# ---------------------------------------------------------------------------
# Type detection
# ---------------------------------------------------------------------------
defp ash_resource?(mod) do
try do
extensions = spark_extensions(mod)
Ash.Resource in extensions
rescue
_ ->
try do
Spark.implements_behaviour?(mod, Ash.Resource.Behaviour)
rescue
_ -> false
end
end
end
defp reactor_module?(mod) do
try do
function_exported?(mod, :reactor, 0) or
(function_exported?(mod, :spark_dsl_config, 0) and
Reactor in spark_extensions(mod))
rescue
_ -> false
end
end
defp rule_module?(mod) do
# Rule modules use a @behaviour or a custom DSL macro.
# Detection: module exports `evaluate/2` and has @rule_metadata attribute,
# OR its name ends in .Rules.Something.
function_exported?(mod, :evaluate, 2) and
match?([_ | _], mod |> to_string() |> String.split(".") |> Enum.filter(&(&1 == "Rules")))
end
defp blueprint_module?(mod) do
function_exported?(mod, :config_schema, 0) and
function_exported?(mod, :eligibility_rules, 0)
end
defp live_page_module?(mod) do
function_exported?(mod, :__live_resource__, 0)
end
defp page_module?(mod) do
# A page module is a Phoenix LiveView discovered via router introspection
# or explicitly declared with @page_group annotation.
phoenix_live_view?(mod)
end
defp phoenix_live_view?(mod) do
try do
# Check if module has mount/3 function and is a Phoenix.LiveView
has_mount =
mod.__info__(:functions)
|> Enum.any?(fn {name, arity} -> name == :mount and arity == 3 end)
has_behaviour =
Keyword.has_key?(mod.__info__(:attributes), :behaviour) and
:phoenix_live_view in (mod.__info__(:attributes)[:behaviour] || [])
has_mount and has_behaviour
rescue
_ -> false
end
end
defp adapter_module?(mod) do
function_exported?(mod, :verify_contract, 0) and
function_exported?(mod, :adapter_name, 0)
end
defp spark_extensions(mod) do
mod
|> Spark.Dsl.Extension.get_persisted(:extensions)
|> Kernel.||([])
rescue
_ -> []
end
# ---------------------------------------------------------------------------
# Context building
# ---------------------------------------------------------------------------
defp build_context(mod, sensitive_set, project_root, page_routes_map) do
mod_str = to_string(mod)
type = detect_type(mod)
domain = detect_domain(mod)
# Extract page metadata if this is a page module
{page_route, page_dynamic, page_group, page_subtype, calls_actions, page_feature_flags} =
if type == :page do
page_meta = PageMetadata.analyze(mod, Map.get(page_routes_map, mod))
{
page_meta[:page_route],
page_meta[:page_dynamic] || false,
page_meta[:page_group],
page_meta[:page_subtype],
page_meta[:calls_actions] || [],
page_meta[:feature_flags] || []
}
else
{nil, false, nil, nil, [], []}
end
%ModuleContext{
module: mod_str,
type: type,
domain: domain,
description: module_description(mod),
steps: transfer_steps(mod, type),
rules: applied_rules(mod, type),
compliance: compliance_links(mod),
runbook: runbook_path(mod),
invariants: spec_invariants(mod),
related_resources: related_resources(mod, type),
adrs: adr_references(mod),
last_modified: last_modified(mod, project_root),
sensitive: MapSet.member?(sensitive_set, mod_str) or auth_resource?(mod),
test_coverage: test_coverage(mod, project_root),
data_layer: data_layer(mod, type),
pending_migrations: pending_migrations?(mod, type),
paper_trail: has_extension?(mod, AshPaperTrail.Resource),
archival: has_extension?(mod, AshArchival.Resource),
state_machine: state_machine_info(mod),
api_routes: api_routes(mod),
telemetry_prefix: telemetry_prefix(mod),
money_attributes: money_attributes(mod, type),
authentication_subject: auth_resource?(mod),
oban_queues: oban_queues(mod, type),
rate_limited: rate_limited?(mod),
feature_flags: if(type == :page, do: page_feature_flags, else: feature_flags(mod)),
page_route: page_route,
page_group: page_group,
page_dynamic: page_dynamic,
page_subtype: page_subtype,
calls_actions: calls_actions
}
end
# ---------------------------------------------------------------------------
# Field extractors
# ---------------------------------------------------------------------------
defp detect_type(mod) do
cond do
auth_resource?(mod) -> :resource
ash_resource?(mod) -> :resource
transfer_module?(mod) -> :transfer
oban_worker?(mod) -> :oban_job
rule_module?(mod) -> :rule
blueprint_module?(mod) -> :blueprint
page_module?(mod) -> :page
live_page_module?(mod) -> :live_page
adapter_module?(mod) -> :adapter
true -> :resource
end
end
defp transfer_module?(mod) do
reactor_module?(mod) and function_exported?(mod, :__transfer_dsl__, 0)
end
defp oban_worker?(mod) do
function_exported?(mod, :perform, 1) and
Code.ensure_loaded?(Oban.Worker) and
Oban.Worker in (mod.__info__(:attributes)[:behaviour] || [])
rescue
_ -> false
end
defp auth_resource?(mod) do
try do
extensions = spark_extensions(mod)
AshAuthentication in extensions or
AshAuthentication.TokenResource in extensions
rescue
_ -> false
end
end
defp detect_domain(mod) do
# Domain = Ash domain name (second segment after app root).
# Elixir.IgamingRef.Finance.Wallet → "Finance"
# Elixir.IgamingRef.Promotions.Rules.PlayerEligibleForCampaign → "Promotions"
parts = mod |> to_string() |> String.split(".")
case parts do
["Elixir", _app, domain | _rest] -> domain
[_app, domain | _rest] -> domain
[_app] -> "Root"
_ -> "Unknown"
end
end
defp module_description(mod) do
case Code.fetch_docs(mod) do
{:docs_v1, _, _, _, %{"en" => doc}, _, _} ->
doc
|> String.split("\n\n")
|> List.first()
|> String.trim()
_ ->
mod |> to_string() |> String.split(".") |> List.last()
end
end
defp transfer_steps(mod, :transfer) do
try do
mod.__transfer_dsl__()[:steps]
|> Enum.map(&to_string/1)
rescue
_ ->
# Fallback: read Reactor steps via Spark introspection
Spark.Dsl.Extension.get_entities(mod, [:reactor, :steps])
|> Enum.map(&(&1.name |> to_string()))
end
end
defp transfer_steps(_, _), do: []
defp applied_rules(mod, :transfer) do
try do
mod.__transfer_dsl__()[:rules] |> Enum.map(&to_string/1)
rescue
_ -> []
end
end
defp applied_rules(_, _), do: []
defp compliance_links(mod) do
try do
mod.__info__(:attributes)[:compliance] || []
rescue
_ -> []
end
|> Enum.map(&to_string/1)
end
defp runbook_path(mod) do
try do
mod.__info__(:attributes)[:runbook]
|> List.wrap()
|> List.first()
rescue
_ -> nil
end
end
defp spec_invariants(mod) do
try do
(mod.__info__(:attributes)[:spec_invariants] || [])
|> Enum.map(&to_string/1)
rescue
_ -> []
end
end
defp related_resources(mod, type) when type in [:resource, :transfer] do
try do
case type do
:resource ->
Ash.Resource.Info.relationships(mod)
|> Enum.map(&(&1.destination |> to_string()))
:transfer ->
# Extract from Reactor step inputs that reference Ash resources
[]
end
rescue
_ -> []
end
end
defp related_resources(_, _), do: []
defp adr_references(mod) do
# Parse @moduledoc for "ADR-NNN" references
case Code.fetch_docs(mod) do
{:docs_v1, _, _, _, %{"en" => doc}, _, _} ->
Regex.scan(~r/ADR-\d{3}/, doc)
|> List.flatten()
|> Enum.uniq()
_ ->
[]
end
end
defp last_modified(mod, project_root) do
# Find the source file via __info__ and read its mtime
try do
case mod.__info__(:compile)[:source] do
nil ->
nil
source ->
path = List.to_string(source)
rel = Path.relative_to(path, project_root)
case File.stat(Path.join(project_root, rel)) do
{:ok, %{mtime: mtime}} ->
mtime
|> NaiveDateTime.from_erl!()
|> NaiveDateTime.to_date()
|> Date.to_iso8601()
_ ->
nil
end
end
rescue
_ -> nil
end
end
defp test_coverage(mod, project_root) do
mod_name = mod |> to_string() |> String.split(".") |> List.last()
test_dir = Path.join(project_root, "test")
property_tests =
file_contains_pattern?(test_dir, "#{mod_name}Test", "stream_data") or
file_contains_pattern?(test_dir, "#{mod_name}Test", "property test")
scenario_tests =
file_contains_pattern?(test_dir, "#{mod_name}", "scenario") or
file_contains_pattern?(test_dir, "#{mod_name}", "@tag :scenario")
e2e_tests =
file_contains_pattern?(test_dir, "#{mod_name}", ":compliance") or
file_contains_pattern?(test_dir, "#{mod_name}", ":e2e")
%{
property_tests: property_tests,
scenario_tests: scenario_tests,
e2e_tests: e2e_tests,
scenario_count: 0
}
end
defp file_contains_pattern?(test_dir, module_name, pattern) do
case File.ls(test_dir) do
{:ok, entries} ->
entries
|> Enum.filter(&String.contains?(&1, String.downcase(module_name)))
|> Enum.any?(fn file ->
path = Path.join(test_dir, file)
case File.read(path) do
{:ok, content} -> String.contains?(content, pattern)
_ -> false
end
end)
_ ->
false
end
end
defp data_layer(mod, type) when type == :resource do
try do
mod
|> Spark.Dsl.Extension.get_persisted(:data_layer)
|> case do
Ash.DataLayer.Postgres -> "ash_postgres"
AshPostgres.DataLayer -> "ash_postgres"
Ash.DataLayer.Ets -> "ash_ets"
Ash.DataLayer.Simple -> "simple"
other when not is_nil(other) -> to_string(other)
nil -> nil
end
rescue
_ -> nil
end
end
defp data_layer(_, _), do: nil
defp pending_migrations?(_mod, :resource) do
# Delegate to `mix ash.codegen --check` exit code via subprocess.
# Returns false here — the Mix task itself performs the check at aggregate level.
# Individual resource pending-migration status is too expensive to check per-module.
false
end
defp pending_migrations?(_, _), do: false
defp has_extension?(mod, extension) do
extension in spark_extensions(mod)
rescue
_ -> false
end
defp state_machine_info(mod) do
base = %{present: false, states: [], transitions: [], state_attribute: nil}
try do
unless has_extension?(mod, AshStateMachine), do: throw(:no_sm)
states =
Spark.Dsl.Extension.get_entities(mod, [:state_machine, :states])
|> Enum.map(&(&1.name |> to_string()))
transitions =
Spark.Dsl.Extension.get_entities(mod, [:state_machine, :transitions])
|> Enum.map(fn t ->
%{
from: t.from |> List.wrap() |> Enum.map(&to_string/1) |> Enum.join("|"),
to: to_string(t.to),
action: to_string(t.action)
}
end)
state_attr =
Spark.Dsl.Extension.get_opt(mod, [:state_machine], :state_attribute, :state)
|> to_string()
%{
base
| present: true,
states: states,
transitions: transitions,
state_attribute: state_attr
}
rescue
_ -> base
catch
_ -> base
end
end
defp api_routes(mod) do
try do
Spark.Dsl.Extension.get_entities(mod, [:json_api, :routes])
|> Enum.map(fn route ->
%{
path: route.route,
method: route.method |> to_string() |> String.upcase(),
auth_required: route.primary_key_source != nil
}
end)
rescue
_ -> []
end
end
defp telemetry_prefix(mod) do
try do
(mod.__info__(:attributes)[:telemetry_prefix] || [])
|> List.flatten()
|> Enum.map(&to_string/1)
rescue
_ -> []
end
end
defp money_attributes(mod, :resource) do
try do
Ash.Resource.Info.attributes(mod)
|> Enum.filter(fn attr ->
attr.type == Ash.Type.Money or
(is_tuple(attr.type) and elem(attr.type, 0) == Ash.Type.Money)
end)
|> Enum.map(fn attr ->
cldr_backend =
attr.constraints[:storage_type]
|> then(fn _ ->
mod.__info__(:attributes)[:cldr_backend]
|> List.wrap()
|> List.first()
|> to_string()
end)
%{name: to_string(attr.name), type: "Ash.Type.Money", cldr_backend: cldr_backend}
end)
rescue
_ -> []
end
end
defp money_attributes(_, _), do: []
defp oban_queues(mod, :oban_job) do
try do
queue = mod.__info__(:attributes)[:queue] || []
queue |> List.wrap() |> Enum.map(&to_string/1)
rescue
_ -> []
end
end
defp oban_queues(_, _), do: []
defp rate_limited?(mod) do
try do
plugs = mod.__info__(:attributes)[:plug] || []
Enum.any?(plugs, fn
{HammerPlug, _} -> true
HammerPlug -> true
_ -> false
end)
rescue
_ -> false
end
end
defp feature_flags(mod) do
try do
(mod.__info__(:attributes)[:feature_flags] || [])
|> Enum.map(&to_string/1)
rescue
_ -> []
end
end
# ---------------------------------------------------------------------------
# Manifest helpers
# ---------------------------------------------------------------------------
defp load_manifest(project_root) do
path = Path.join([project_root, ".foundry", "manifest.exs"])
case File.read(path) do
{:ok, content} ->
{kw, _} = Code.eval_string(content)
kw
_ ->
[]
end
end
defp sensitive_set(manifest) do
(manifest[:sensitive_resources] || [])
|> Enum.map(&to_string/1)
|> MapSet.new()
end
defp excluded_set(manifest) do
(manifest[:context_exclusions] || [])
|> Enum.map(&to_string/1)
|> MapSet.new()
end
end