Summary
Go-to-definition on a function generated by the Ash DSL resolves only to line 1 of the module, not to the define line that generates it. dexter lookup --strict exits 1 for the same call.
Ash domains are common in Phoenix apps, and most calls into an Ash app go through these generated "code interface" functions. It would be great if Dexter could resolve them to the define line. Is there a way to support this, or a hook for DSL-generated functions?
Environment
- Dexter 0.7.2 (nixpkgs), macOS 26.6 on arm64
- Elixir 1.20.4, OTP 28
- ash 3.33.9, spark 2.7.3 (the full app also uses ash_postgres 2.13.1, phoenix 1.8.14)
Minimal reproduction
A standalone project with the ETS data layer, so no database is needed.
mix.exs
defmodule Repro.MixProject do
use Mix.Project
def project do
[app: :repro, version: "0.1.0", elixir: "~> 1.18", deps: [{:ash, "~> 3.33"}]]
end
end
config/config.exs
import Config
config :ash, default_string_length_count: :codepoints
lib/repro/chat.ex
defmodule Repro.Chat do
use Ash.Domain, validate_config_inclusion?: false
resources do
resource Repro.Chat.Room do
define :list_rooms, action: :read
define :get_room_by_slug, action: :read, get_by: [:slug]
define :create_room, action: :create
end
end
end
lib/repro/chat/room.ex
defmodule Repro.Chat.Room do
use Ash.Resource, domain: Repro.Chat, data_layer: Ash.DataLayer.Ets
actions do
defaults [:read, create: [:name, :slug]]
end
attributes do
uuid_primary_key :id
attribute :name, :string, allow_nil?: false, public?: true
attribute :slug, :string, allow_nil?: false, public?: true
end
identities do
identity :slug, [:slug], pre_check_with: Repro.Chat
end
end
lib/repro/caller.ex
defmodule Repro.Caller do
alias Repro.Chat
def run do
{:ok, _room} = Chat.create_room(%{name: "Lounge", slug: "lounge"})
Chat.get_room_by_slug!("lounge")
end
end
Steps:
mix deps.get && mix compile
mix run -e 'IO.inspect(Repro.Caller.run().name)' # prints "Lounge": the function exists
dexter init .
dexter lookup Repro.Chat get_room_by_slug! # .../lib/repro/chat.ex:1
dexter lookup --strict Repro.Chat get_room_by_slug! # no output, exit 1
Then ask the LSP for the definition of get_room_by_slug! in lib/repro/caller.ex, line 6, column 10.
Expected: lib/repro/chat.ex:7, the define :get_room_by_slug, ... line.
Actual: lib/repro/chat.ex:1. Debug log from dexter lsp with debug: true:
[debug] Definition: expr="Chat.get_room_by_slug!" module="Chat" function="get_room_by_slug!"
[debug] Definition: qualified call resolved "Chat" -> "Repro.Chat"
[debug] Definition: no result for Repro.Chat.get_room_by_slug!
=> [{"range":{"start":{"character":0,"line":0},"end":{"character":0,"line":0}},"uri":"file:///.../lib/repro/chat.ex"}]
What Ash generates, and where the line lives
For each define :name inside resources do resource X do ... end end in a module that does use Ash.Domain, Ash generates several public functions in the domain module. For the read in this repro, the compiled module has:
get_room_by_slug/1..3 and get_room_by_slug!/1..3
can_get_room_by_slug/2..4 and can_get_room_by_slug?/2..4
query_to_get_room_by_slug/1..3
Resources can declare the same thing in a code_interface do define ... end block, which generates the functions on the resource module instead. I only tested the domain form.
The compiled BEAM does not help: every generated clause has line: 1 in the debug info definitions, and the docs chunk anno is 1 too. Spark does keep the real location on the DSL entity, though. In the full app below, define :get_room_by_slug is on line 18 of the domain, and:
AgenticLiveview.ChatAsh
|> Ash.Domain.Info.resource_references()
|> Enum.flat_map(& &1.definitions)
|> Enum.find(&(&1.name == :get_room_by_slug))
|> Spark.Dsl.Entity.anno()
#=> [file: ~c".../lib/agentic_liveview/chat_ash.ex", location: 18]
Possible approaches
- Static: when a module does
use Ash.Domain, index each define :name inside resources do ... resource X do ... end as the definition of name, name!, can_name, can_name? and query_to_name. Do the same for code_interface do define :name in use Ash.Resource modules. define_calculation :name generates name/.. too.
- Runtime, through the BEAM process Dexter already runs: when the store has no hit and the module is a Spark DSL module, ask Spark for the entity's anno.
- Generic: a small config or plugin hook that maps a DSL call such as
define :name to generated function names, so Dexter need not know about Ash specifically.
I'm happy to help test whichever direction fits Dexter's design.
A related case that is not a Dexter bug
From Claude Code's LSP tool, go-to-definition into deps/ (for example Phoenix.PubSub.broadcast/3) returned nothing. Driving dexter lsp directly over stdio returns the right deps/phoenix_pubsub/lib/phoenix/pubsub.ex location, and workspace/symbol returns deps/ results too. The client is dropping locations in git-ignored folders, so I'm reporting that to Claude Code instead.
Full Ash setup from the real app
This is the chat-room part of a Phoenix app, where I first saw this. The call that fails is ChatAsh.get_room_by_slug!(scope: scope) in ChatBackend. It resolves to chat_ash.ex:1 instead of line 18. In the same line, ChatAsh.to_chat_room/1, a plain def, resolves correctly to both clauses. The resources use AshPostgres against existing tables, so they need a Postgres database to run, but Dexter only needs the source.
lib/agentic_liveview/chat_ash.ex (the domain; line 18 is the define)
defmodule AgenticLiveview.ChatAsh do
@moduledoc """
Ash spike (see `.claude/plans/ash-adoption`): rooms, members, and messages
as Ash resources on the same tables as `AgenticLiveview.Chat`.
Authorization lives in each resource's policies and takes the caller's
`%Scope{}` through `scope:`. A read the caller may not make returns no rows;
a write returns `{:error, %Ash.Error.Forbidden{}}`.
"""
use Ash.Domain, otp_app: :agentic_liveview
alias AgenticLiveview.Accounts.Scope
resources do
resource AgenticLiveview.ChatAsh.Room do
define :list_rooms, action: :list
define :first_room, action: :first, get?: true, not_found_error?: false
define :get_room_by_slug, action: :read, get_by: [:slug], not_found_error?: false
define :seed_room, action: :seed
end
resource AgenticLiveview.ChatAsh.RoomMember do
define :list_members, action: :read
define :invite_member, action: :invite, args: [:room_id, :user_id]
end
resource AgenticLiveview.ChatAsh.Message do
define :list_room_messages, action: :in_room, args: [:room_id, {:optional, :before_id}]
define :get_message, action: :read, get_by: [:id]
define :post_message, action: :post, args: [:room_id]
define :edit_message, action: :edit
define :delete_message, action: :destroy
end
resource AgenticLiveview.ChatAsh.Reaction
resource AgenticLiveview.ChatAsh.Attachment
end
@doc """
The most recent page of a room's messages, oldest first, and whether older
ones exist. A room the member may not see yields no messages.
"""
def list_recent_messages(%Scope{} = scope, room_id),
do: list_messages_before(scope, room_id, nil)
@doc "A page of messages older than `before_id`, oldest first, and whether more exist."
def list_messages_before(%Scope{} = scope, room_id, before_id) do
page = list_room_messages!(room_id, before_id, scope: scope)
{Enum.reverse(page.results), page.more?}
end
@doc "The latest message of every visible room, keyed by room id, with the author loaded."
def latest_messages_by_room(%Scope{} = scope) do
rooms = list_rooms!(scope: scope, load: [latest_message: [:user]])
for %{latest_message: %{} = message} = room <- rooms, into: %{}, do: {room.id, message}
end
@doc """
Spike glue: the Ecto `%Chat.Room{}` that every `Chat` function still
pattern-matches on, built from a room the policies allowed. Goes away once
those functions move to this domain.
"""
def to_chat_room(nil), do: nil
def to_chat_room(%AgenticLiveview.ChatAsh.Room{} = room) do
fields = AgenticLiveview.Chat.Room.__schema__(:fields)
struct(AgenticLiveview.Chat.Room, Map.take(room, fields))
end
@doc """
Publishes an Ash notification on `AgenticLiveview.PubSub`.
Not called from this codebase: `Ash.Notifier.PubSub` invokes it by name after
each commit, with the payload the publication's `transform` built, so that
subscribers of `Chat.subscribe/1` and `Chat.subscribe_all/0` receive the same
tuples they would from the Ecto context.
"""
def broadcast(topic, _event, payload) do
Phoenix.PubSub.broadcast(AgenticLiveview.PubSub, topic, payload)
end
end
lib/agentic_liveview/chat_backend.ex (the caller)
defmodule AgenticLiveview.ChatBackend do
@moduledoc """
The seam the Ash spike is measured through (`.claude/plans/ash-adoption`).
`ChatLive` makes four reads the spike replaces: the room lookups behind
`/chat` and `/chat/:slug`, and the two pages of message history. Every other
call it makes still goes to `AgenticLiveview.Chat`, against the same tables.
`config :agentic_liveview, :chat_backend` chooses the implementation - `:ecto`
by default, `:ash` to route those four through `ChatAsh`'s policies instead.
It is read per call rather than at compile time, so a test can flip it.
Rooms are handed back as `%Chat.Room{}` either way, because the rest of
`ChatLive` passes the room to `Chat` functions that pattern-match on it.
Messages are not converted: under `:ash` the stream carries
`%ChatAsh.Message{}`, which is the point - it is how the spike finds out
whether the view and DenUI can live on plain loaded data.
"""
alias AgenticLiveview.Accounts.Scope
alias AgenticLiveview.Chat
alias AgenticLiveview.ChatAsh
@doc "The room behind `/chat/:slug`, or `nil` when it does not exist or is not visible."
def get_room_by_slug(%Scope{} = scope, slug) do
case backend() do
:ecto -> Chat.get_room_by_slug(scope, slug)
:ash -> slug |> ChatAsh.get_room_by_slug!(scope: scope) |> ChatAsh.to_chat_room()
end
end
@doc "The room `/chat` redirects to, or `nil` when the caller can see none."
def first_room(%Scope{} = scope) do
case backend() do
:ecto -> Chat.first_room(scope)
:ash -> [scope: scope] |> ChatAsh.first_room!() |> ChatAsh.to_chat_room()
end
end
@doc "The newest page of a room's messages, oldest first, and whether older ones exist."
def list_recent_messages(%Scope{} = scope, room) do
case backend() do
:ecto -> Chat.list_recent_messages(scope, room)
:ash -> ChatAsh.list_recent_messages(scope, room.id)
end
end
@doc "A page older than `before_id`, oldest first, and whether more exist."
def list_messages_before(%Scope{} = scope, room, before_id) do
case backend() do
:ecto -> Chat.list_messages_before(scope, room, before_id)
:ash -> ChatAsh.list_messages_before(scope, room.id, before_id)
end
end
@doc "Which implementation the four reads above are using right now."
def backend, do: Application.get_env(:agentic_liveview, :chat_backend, :ecto)
end
lib/agentic_liveview/chat_ash/room.ex
defmodule AgenticLiveview.ChatAsh.Room do
@moduledoc """
A chat room. Private rooms are visible only to their members; the read
policy below is the whole rule, so every read action inherits it.
"""
use Ash.Resource,
domain: AgenticLiveview.ChatAsh,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
postgres do
table "rooms"
repo AgenticLiveview.Repo
# The table is owned by priv/repo/migrations.
migrate? false
identity_index_names slug: "rooms_slug_index"
end
actions do
# The primary read stays bare: Ash uses it for relationship loads and
# policy checks. Listing sorts seeded rooms first, then member-created
# ones in creation order.
defaults [:read]
read :list do
prepare build(sort: [position: :asc, id: :asc])
end
read :first do
prepare build(sort: [position: :asc, id: :asc], limit: 1)
end
# Seeds only (`priv/repo/seeds.exs` runs with `authorize?: false`).
create :seed do
accept [:name, :slug, :emoji, :description, :position, :private]
end
end
policies do
policy action_type(:read) do
forbid_unless actor_present()
authorize_if expr(not private)
authorize_if expr(exists(room_members, user_id == ^actor(:id)))
end
policy action(:seed) do
forbid_if always()
end
end
attributes do
integer_primary_key :id
attribute :name, :string, allow_nil?: false, public?: true
attribute :slug, :string, allow_nil?: false, public?: true
attribute :emoji, :string, allow_nil?: false, public?: true
attribute :description, :string,
allow_nil?: false,
default: "",
public?: true,
constraints: [allow_empty?: true, trim?: true]
attribute :position, :integer, allow_nil?: false, default: 0, public?: true
attribute :private, :boolean, allow_nil?: false, default: false, public?: true
create_timestamp :inserted_at, type: :utc_datetime
update_timestamp :updated_at, type: :utc_datetime
end
relationships do
belongs_to :creator, AgenticLiveview.AccountsAsh.User, attribute_type: :integer
has_many :messages, AgenticLiveview.ChatAsh.Message
has_many :room_members, AgenticLiveview.ChatAsh.RoomMember
# The newest message, for the room list preview.
has_one :latest_message, AgenticLiveview.ChatAsh.Message do
from_many? true
sort id: :desc
end
end
aggregates do
count :member_count, :room_members
first :latest_message_id, :messages, :id, sort: [id: :desc]
end
identities do
identity :slug, [:slug]
end
end
lib/agentic_liveview/chat_ash/room_member.ex
defmodule AgenticLiveview.ChatAsh.RoomMember do
@moduledoc """
A member of a private room. Public rooms have no member rows: every
signed-in member can read and write them. The creator of a room is its
`owner`.
"""
use Ash.Resource,
domain: AgenticLiveview.ChatAsh,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
postgres do
table "room_members"
repo AgenticLiveview.Repo
# The table is owned by priv/repo/migrations.
migrate? false
identity_index_names membership: "room_members_room_id_user_id_index"
end
actions do
defaults [:read]
# Adds a member to a private room. Adding someone who already belongs is
# a no-op that returns the existing row: the upsert touches only the
# identity columns, so the role is never downgraded.
create :invite do
accept [:room_id, :user_id]
upsert? true
upsert_identity :membership
upsert_fields []
end
end
policies do
# Member rows are visible to the members of that room.
policy action_type(:read) do
forbid_unless actor_present()
authorize_if expr(exists(room.room_members, user_id == ^actor(:id)))
end
# Any current member may invite. The check runs against the inserted row
# inside the transaction, so the new row itself is excluded: a member
# cannot invite themself into a room they do not belong to.
policy action(:invite) do
forbid_unless actor_present()
forbid_unless expr(room.private)
authorize_if expr(exists(room.room_members, user_id == ^actor(:id) and id != parent(id)))
end
end
attributes do
integer_primary_key :id
attribute :role, :string,
allow_nil?: false,
default: "member",
constraints: [match: ~r/\A(owner|member)\z/]
create_timestamp :inserted_at, type: :utc_datetime
update_timestamp :updated_at, type: :utc_datetime
end
relationships do
belongs_to :room, AgenticLiveview.ChatAsh.Room, allow_nil?: false, attribute_type: :integer
belongs_to :user, AgenticLiveview.AccountsAsh.User,
allow_nil?: false,
attribute_type: :integer
end
identities do
identity :membership, [:room_id, :user_id]
end
end
lib/agentic_liveview/chat_ash/message.ex
defmodule AgenticLiveview.ChatAsh.Message do
@moduledoc "A message posted by a member in a room, with optional attachments and reactions."
use Ash.Resource,
domain: AgenticLiveview.ChatAsh,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer],
notifiers: [Ash.Notifier.PubSub]
@page_size 50
@max_length 1000
postgres do
table "messages"
repo AgenticLiveview.Repo
# The table is owned by priv/repo/migrations.
migrate? false
end
actions do
defaults [:read, :destroy]
# A page of a room's messages, newest first. `more?` on the returned page
# says whether older messages exist.
read :in_room do
argument :room_id, :integer, allow_nil?: false
argument :before_id, :integer
filter expr(
room_id == ^arg(:room_id) and (is_nil(^arg(:before_id)) or id < ^arg(:before_id))
)
prepare build(sort: [id: :desc], load: [:user, :attachments, :reactions])
pagination keyset?: true,
default_limit: @page_size,
max_page_size: @page_size,
required?: true
end
create :post do
accept [:body]
argument :room_id, :integer, allow_nil?: false
change set_attribute(:room_id, arg(:room_id))
# Not `relate_actor(:user)`: the actor is the Ecto `%Accounts.User{}`,
# which cannot stand in for the Ash `User` record when `user` is loaded.
change set_attribute(:user_id, actor(:id))
end
# Only the author may edit (see policies); the message is marked as edited.
update :edit do
accept [:body]
change set_attribute(:edited_at, &DateTime.utc_now/0)
end
end
policies do
# Readable when the room is: public, or private with the actor as a member.
policy action_type(:read) do
forbid_unless actor_present()
authorize_if expr(not room.private)
authorize_if expr(exists(room.room_members, user_id == ^actor(:id)))
end
# Posting follows the same rule, checked against the inserted row inside
# the transaction.
policy action(:post) do
forbid_unless actor_present()
authorize_if expr(not room.private)
authorize_if expr(exists(room.room_members, user_id == ^actor(:id)))
end
policy action([:edit, :destroy]) do
authorize_if expr(user_id == ^actor(:id))
end
end
pub_sub do
module AgenticLiveview.ChatAsh
# The room topic (`Chat.subscribe/1`) carries the record; the broad
# "rooms" topic (`Chat.subscribe_all/0`) carries the room id so each
# subscriber can check it against their own room list.
publish :post, ["room", :room_id],
load: [:user, :attachments, :reactions],
transform: &{:message_created, &1.data}
publish :post, "rooms",
transform: &{:room_activity, &1.data.room_id, {:message_created, &1.data}}
publish :edit, ["room", :room_id],
load: [:user, :attachments, :reactions],
transform: &{:message_updated, &1.data}
publish :edit, "rooms",
transform: &{:room_activity, &1.data.room_id, {:message_updated, &1.data}}
publish :destroy, ["room", :room_id], transform: &{:message_deleted, &1.data}
publish :destroy, "rooms",
transform: &{:room_activity, &1.data.room_id, {:message_deleted, &1.data}}
end
attributes do
integer_primary_key :id
# Whitespace is trimmed and an empty body is rejected; attachment-only
# messages are a Phase 1 concern.
attribute :body, :string,
allow_nil?: false,
public?: true,
constraints: [trim?: true, allow_empty?: false, max_length: @max_length]
attribute :edited_at, :utc_datetime
create_timestamp :inserted_at, type: :utc_datetime
update_timestamp :updated_at, type: :utc_datetime
end
relationships do
belongs_to :room, AgenticLiveview.ChatAsh.Room, allow_nil?: false, attribute_type: :integer
belongs_to :user, AgenticLiveview.AccountsAsh.User,
allow_nil?: false,
attribute_type: :integer
has_many :attachments, AgenticLiveview.ChatAsh.Attachment
has_many :reactions, AgenticLiveview.ChatAsh.Reaction
end
def page_size, do: @page_size
def max_length, do: @max_length
end
lib/agentic_liveview/chat_ash/reaction.ex
defmodule AgenticLiveview.ChatAsh.Reaction do
@moduledoc "An emoji reaction by a member on a message. Read-only in the spike."
use Ash.Resource,
domain: AgenticLiveview.ChatAsh,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
postgres do
table "message_reactions"
repo AgenticLiveview.Repo
# The table is owned by priv/repo/migrations.
migrate? false
identity_index_names one_per_member: "message_reactions_message_id_user_id_emoji_index"
end
actions do
defaults [:read]
end
policies do
# Visible with the message, so with the room.
policy action_type(:read) do
forbid_unless actor_present()
authorize_if expr(not message.room.private)
authorize_if expr(exists(message.room.room_members, user_id == ^actor(:id)))
end
end
attributes do
integer_primary_key :id
attribute :emoji, :string, allow_nil?: false, public?: true
create_timestamp :inserted_at, type: :utc_datetime
update_timestamp :updated_at, type: :utc_datetime
end
relationships do
belongs_to :message, AgenticLiveview.ChatAsh.Message,
allow_nil?: false,
attribute_type: :integer
belongs_to :user, AgenticLiveview.AccountsAsh.User,
allow_nil?: false,
attribute_type: :integer
end
identities do
identity :one_per_member, [:message_id, :user_id, :emoji]
end
end
lib/agentic_liveview/chat_ash/attachment.ex
defmodule AgenticLiveview.ChatAsh.Attachment do
@moduledoc "A file attached to a message, stored under the uploads directory. Read-only in the spike."
use Ash.Resource,
domain: AgenticLiveview.ChatAsh,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
postgres do
table "message_attachments"
repo AgenticLiveview.Repo
# The table is owned by priv/repo/migrations.
migrate? false
end
actions do
defaults [:read]
end
policies do
# Visible with the message, so with the room.
policy action_type(:read) do
forbid_unless actor_present()
authorize_if expr(not message.room.private)
authorize_if expr(exists(message.room.room_members, user_id == ^actor(:id)))
end
end
attributes do
integer_primary_key :id
attribute :name, :string, allow_nil?: false, public?: true
# File name inside the uploads directory.
attribute :path, :string, allow_nil?: false, public?: true
attribute :content_type, :string, allow_nil?: false, public?: true
attribute :size, :integer, allow_nil?: false, public?: true
create_timestamp :inserted_at, type: :utc_datetime
update_timestamp :updated_at, type: :utc_datetime
end
relationships do
belongs_to :message, AgenticLiveview.ChatAsh.Message,
allow_nil?: false,
attribute_type: :integer
end
end
lib/agentic_liveview/accounts_ash.ex
defmodule AgenticLiveview.AccountsAsh do
@moduledoc """
Ash spike (see `.claude/plans/ash-adoption`): a read-only view of members
so chat resources can load message authors. Sign-in and settings stay in
`AgenticLiveview.Accounts`.
"""
use Ash.Domain, otp_app: :agentic_liveview
resources do
resource AgenticLiveview.AccountsAsh.User
end
end
lib/agentic_liveview/accounts_ash/user.ex
defmodule AgenticLiveview.AccountsAsh.User do
@moduledoc """
A member, as chat resources see them: enough to render an author. Read-only
in the spike; `AgenticLiveview.Accounts.User` still owns writes.
"""
use Ash.Resource,
domain: AgenticLiveview.AccountsAsh,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
postgres do
table "users"
repo AgenticLiveview.Repo
# The users table is owned by priv/repo/migrations.
migrate? false
end
actions do
defaults [:read]
end
policies do
# Mentions, member lists, and message authors: every signed-in member may
# see every other member. Nothing is readable anonymously.
policy action_type(:read) do
authorize_if actor_present()
end
end
attributes do
integer_primary_key :id
attribute :name, :string, allow_nil?: false, public?: true
attribute :email, :string, sensitive?: true
attribute :avatar_url, :string, public?: true
attribute :provider_avatar_url, :string
attribute :avatar_path, :string
attribute :provider, :string, allow_nil?: false
attribute :provider_uid, :string, allow_nil?: false
attribute :email_on_mention, :boolean, allow_nil?: false, default: true
attribute :time_format, :string, allow_nil?: false, default: "24h"
create_timestamp :inserted_at, type: :utc_datetime
update_timestamp :updated_at, type: :utc_datetime
end
identities do
identity :provider_uid, [:provider, :provider_uid]
end
end
lib/agentic_liveview/accounts/scope.ex (Ash.Scope.ToOpts impl)
defmodule AgenticLiveview.Accounts.Scope do
@moduledoc """
The caller's identity for context functions and LiveViews.
See https://phoenix.hexdocs.pm/scopes.html. A scope with a `nil` user is an
anonymous visitor.
"""
alias AgenticLiveview.Accounts.User
defstruct user: nil
def for_user(%User{} = user), do: %__MODULE__{user: user}
def for_user(nil), do: nil
end
# Lets Ash actions take `scope: scope` and read the actor from it. The app has
# no tenants, tracer, or per-call context, so those stay at Ash's defaults.
defimpl Ash.Scope.ToOpts, for: AgenticLiveview.Accounts.Scope do
def get_actor(%{user: user}), do: {:ok, user}
def get_tenant(_scope), do: :error
def get_context(_scope), do: :error
def get_tracer(_scope), do: :error
def get_authorize?(_scope), do: :error
end
Ash config (config/config.exs)
config :agentic_liveview,
ecto_repos: [AgenticLiveview.Repo],
generators: [timestamp_type: :utc_datetime],
ash_domains: [AgenticLiveview.AccountsAsh, AgenticLiveview.ChatAsh],
# Ash spike (see .claude/plans/ash-adoption): `:ash` routes the chat room
# lookup and message history through the ChatAsh policies; `:ecto` is the
# Chat context. Both read the same tables.
chat_backend: :ecto
config :ash,
allow_forbidden_field_for_relationships_by_default: true,
include_embedded_source_by_default?: false,
show_keysets_for_all_actions?: false,
default_page_type: :keyset,
policies: [no_filter_static_forbidden_reads?: false],
keep_read_action_loads_when_loading?: false,
default_actions_require_atomic?: true,
read_action_after_action_hooks_in_order?: true,
bulk_actions_default_to_errors?: true,
transaction_rollback_on_error?: true,
redact_sensitive_values_in_errors?: true,
default_string_length_count: :codepoints,
many_to_many_destroy_destination_on_match?: true,
known_types: [AshPostgres.Timestamptz, AshPostgres.TimestamptzUsec]
Summary
Go-to-definition on a function generated by the Ash DSL resolves only to line 1 of the module, not to the
defineline that generates it.dexter lookup --strictexits 1 for the same call.Ash domains are common in Phoenix apps, and most calls into an Ash app go through these generated "code interface" functions. It would be great if Dexter could resolve them to the
defineline. Is there a way to support this, or a hook for DSL-generated functions?Environment
Minimal reproduction
A standalone project with the ETS data layer, so no database is needed.
mix.exsconfig/config.exslib/repro/chat.exlib/repro/chat/room.exlib/repro/caller.exSteps:
Then ask the LSP for the definition of
get_room_by_slug!inlib/repro/caller.ex, line 6, column 10.Expected:
lib/repro/chat.ex:7, thedefine :get_room_by_slug, ...line.Actual:
lib/repro/chat.ex:1. Debug log fromdexter lspwithdebug: true:What Ash generates, and where the line lives
For each
define :nameinsideresources do resource X do ... end endin a module that doesuse Ash.Domain, Ash generates several public functions in the domain module. For the read in this repro, the compiled module has:get_room_by_slug/1..3andget_room_by_slug!/1..3can_get_room_by_slug/2..4andcan_get_room_by_slug?/2..4query_to_get_room_by_slug/1..3Resources can declare the same thing in a
code_interface do define ... endblock, which generates the functions on the resource module instead. I only tested the domain form.The compiled BEAM does not help: every generated clause has
line: 1in the debug info definitions, and the docs chunk anno is1too. Spark does keep the real location on the DSL entity, though. In the full app below,define :get_room_by_slugis on line 18 of the domain, and:Possible approaches
use Ash.Domain, index eachdefine :nameinsideresources do ... resource X do ... endas the definition ofname,name!,can_name,can_name?andquery_to_name. Do the same forcode_interface do define :nameinuse Ash.Resourcemodules.define_calculation :namegeneratesname/..too.define :nameto generated function names, so Dexter need not know about Ash specifically.I'm happy to help test whichever direction fits Dexter's design.
A related case that is not a Dexter bug
From Claude Code's LSP tool, go-to-definition into
deps/(for examplePhoenix.PubSub.broadcast/3) returned nothing. Drivingdexter lspdirectly over stdio returns the rightdeps/phoenix_pubsub/lib/phoenix/pubsub.exlocation, andworkspace/symbolreturnsdeps/results too. The client is dropping locations in git-ignored folders, so I'm reporting that to Claude Code instead.Full Ash setup from the real app
This is the chat-room part of a Phoenix app, where I first saw this. The call that fails is
ChatAsh.get_room_by_slug!(scope: scope)inChatBackend. It resolves tochat_ash.ex:1instead of line 18. In the same line,ChatAsh.to_chat_room/1, a plaindef, resolves correctly to both clauses. The resources use AshPostgres against existing tables, so they need a Postgres database to run, but Dexter only needs the source.lib/agentic_liveview/chat_ash.ex (the domain; line 18 is the define)lib/agentic_liveview/chat_backend.ex (the caller)lib/agentic_liveview/chat_ash/room.exlib/agentic_liveview/chat_ash/room_member.exlib/agentic_liveview/chat_ash/message.exlib/agentic_liveview/chat_ash/reaction.exlib/agentic_liveview/chat_ash/attachment.exlib/agentic_liveview/accounts_ash.exlib/agentic_liveview/accounts_ash/user.exlib/agentic_liveview/accounts/scope.ex (Ash.Scope.ToOpts impl)Ash config (
config/config.exs)