Skip to content

Commit bea42a2

Browse files
committed
test: recognise fully qualified Mix.Tasks aliases
`defmodule Elixir.Mix.Tasks.Castle.X` names the same module as `defmodule Mix.Tasks.Castle.X`, but parses to an alias with an extra leading segment - `[Elixir, :Mix, :Tasks, ...]` rather than `[:Mix, :Tasks, ...]` - so the matcher walked straight past it. Guarded by a `Mix.env()` branch it was invisible to the beam check as well, which is the state the two checks exist to make impossible between them. Recognition now strips the leading `Elixir` before testing the segments. `Module.concat/1` already renders both spellings as `Mix.Tasks.Castle.X`, so only the recognition needed to know about the prefix, and the reported name is unchanged. A case pins the qualified form inside an environment branch, alongside the parenthesised and literal-atom ones. Verified against a planted `lib/mix/tasks/qualified.ex`, which the source check now reports and the test-environment beam check still cannot see. Claude-Session: https://claude.ai/code/session_01RotroiBdbidiCRX3KqE3Dy
1 parent bbd096c commit bea42a2

2 files changed

Lines changed: 38 additions & 7 deletions

File tree

AGENTS.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1663,9 +1663,11 @@ the entries, and `castle.ex` among the sources, because an empty directory
16631663
filters to no tasks and that is indistinguishable from a clean result.
16641664

16651665
**What the source check enforces is narrower than "no task in `lib`", and the
1666-
difference is on the record rather than assumed.** It matches the two forms a
1667-
module name is *written* in — an alias, `defmodule Mix.Tasks.X`, and a literal
1668-
atom, `defmodule :"Elixir.Mix.Tasks.X"`. It does not *resolve* names, so
1666+
difference is on the record rather than assumed.** It matches the forms a module
1667+
name is *written* in — an alias, `defmodule Mix.Tasks.X`; the same alias fully
1668+
qualified, `defmodule Elixir.Mix.Tasks.X`, which parses with an extra leading
1669+
segment; and a literal atom, `defmodule :"Elixir.Mix.Tasks.X"`. It does not
1670+
*resolve* names, so
16691671
`alias Mix.Tasks, as: N` followed by `defmodule N.Castle.X` defines the module
16701672
and is invisible to it, as are a name built by `Module.concat/1`, one produced
16711673
by a macro, and `Module.create/3`. Doing better means implementing alias scoping

test/castle/no_mix_tasks_test.exs

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,24 @@ defmodule Castle.NoMixTasksTest do
139139
assert task_definitions(@heredoc_sample, "docs.ex") == []
140140
end
141141

142+
# `Elixir.Mix.Tasks.X` names the same module as `Mix.Tasks.X` and parses with
143+
# an extra leading segment, which the first version of the matcher did not
144+
# expect. Guarded by an environment branch it was invisible to the beam check
145+
# too, so it escaped the pair the same way the parenthesised form did.
146+
test "the source check sees a fully qualified alias" do
147+
source = ~S"""
148+
if Mix.env() == :prod do
149+
defmodule Elixir.Mix.Tasks.Castle.Qualified do
150+
use Mix.Task
151+
end
152+
end
153+
"""
154+
155+
assert task_definitions(source, "qualified.ex") == [
156+
"qualified.ex:2: Mix.Tasks.Castle.Qualified"
157+
]
158+
end
159+
142160
# The other written form of a module name.
143161
test "the source check sees a literal atom module name" do
144162
source = ~S"""
@@ -171,8 +189,9 @@ defmodule Castle.NoMixTasksTest do
171189
# AST node whatever the spacing, parenthesisation or line breaks around it
172190
# are, and text inside a string literal is a binary in that AST rather than a
173191
# node - so both of the cases above fall out of asking the parser instead of
174-
# the characters. It matches the two forms a module name is *written* in: an
175-
# alias (`Mix.Tasks.X`) and a literal atom (`:"Elixir.Mix.Tasks.X"`).
192+
# the characters. It matches the forms a module name is *written* in: an alias
193+
# (`Mix.Tasks.X`), the same alias fully qualified (`Elixir.Mix.Tasks.X`), and a
194+
# literal atom (`:"Elixir.Mix.Tasks.X"`).
176195
#
177196
# **What it does not do is resolve names, and that is a deliberate limit
178197
# rather than an oversight.** `alias Mix.Tasks, as: N` followed by
@@ -200,8 +219,10 @@ defmodule Castle.NoMixTasksTest do
200219

201220
{_ast, found} =
202221
Macro.prewalk(ast, [], fn
203-
{:defmodule, meta, [{:__aliases__, _, [:Mix, :Tasks | _] = segments} | _]} = node, acc ->
204-
{node, [{line(meta), Module.concat(segments)} | acc]}
222+
{:defmodule, meta, [{:__aliases__, _, segments} | _]} = node, acc ->
223+
if task_alias?(segments),
224+
do: {node, [{line(meta), Module.concat(segments)} | acc]},
225+
else: {node, acc}
205226

206227
{:defmodule, meta, [module | _]} = node, acc when is_atom(module) ->
207228
if match?("Elixir.Mix.Tasks." <> _, Atom.to_string(module)),
@@ -217,5 +238,13 @@ defmodule Castle.NoMixTasksTest do
217238
|> Enum.map(fn {line, module} -> "#{label}:#{line}: #{inspect(module)}" end)
218239
end
219240

241+
# `Mix.Tasks.X` parses to `[:Mix, :Tasks, :X]`, and the fully qualified
242+
# `Elixir.Mix.Tasks.X` to `[Elixir, :Mix, :Tasks, :X]` - the same module,
243+
# written twice. `Module.concat/1` already renders both as `Mix.Tasks.X`, so
244+
# only the recognition needs to know about the prefix.
245+
defp task_alias?([Elixir | rest]), do: task_alias?(rest)
246+
defp task_alias?([:Mix, :Tasks | _]), do: true
247+
defp task_alias?(_segments), do: false
248+
220249
defp line(meta), do: Keyword.get(meta, :line, 0)
221250
end

0 commit comments

Comments
 (0)