Skip to content

test: assert that Castle ships no Mix tasks - #35

Merged
ausimian merged 2 commits into
feature/upgrade-toolingfrom
issue/33-no-mix-tasks
Aug 25, 2026
Merged

test: assert that Castle ships no Mix tasks#35
ausimian merged 2 commits into
feature/upgrade-toolingfrom
issue/33-no-mix-tasks

Conversation

@ausimian

Copy link
Copy Markdown
Owner

Closes #33. Part of #32; rationale in design/upgrade-tooling.md §D1.

What the issue asked for

ausimian/forecastle#24 named the build-time tasks mix castle.* while leaving them
compiled inside Forecastle, so Mix.Tasks.Castle.* is a module namespace both
packages could write into. Mix resolves a task by module name alone, so if both ever
defined the same module, whichever ebin came first on the code path would win —
silently, with nothing from the compiler, Mix or the release to say which.

Invariant: Castle ships no Mix tasks. Every task lives in Forecastle, whatever
it is called.

A convention nobody enforces is a convention that lapses, so it fails a build instead.

What changed

test/castle/no_mix_tasks_test.exs, plus the AGENTS.md account of it. Two checks:

  • Beam check — reads Castle's own ebin via Mix.Project.compile_path() and
    rejects any Elixir.Mix.Tasks.*.beam. That filename is Mix's discovery rule:
    Mix.Task.load_all/0 scans the code path for exactly it and consults no application
    metadata. Scoped to Castle's own ebin rather than the code path, because Forecastle's
    is on that path during the test and its Mix.Tasks.Castle.Relup belongs there.
  • Source check — covers what a beam check structurally cannot, a module compiled only
    in an environment mix test does not build, by parsing lib and matching literal
    defmodule AST nodes.

Both assert they are looking at something before concluding it is clean, since an empty
directory filters to no tasks and reads exactly like a clean result.

Verification

Both directions were checked by planting a task, not just by watching green:

  • defmodule Mix.Tasks.Castle.Relup in lib/ → both checks fail, naming the module.
  • A prod-only, parenthesised defmodule(Mix.Tasks.Castle.Hidden) → the source check
    catches it at hidden.ex:4 while the beam check correctly stays silent, which is
    precisely why the source check exists.
  • Clean tree → mix precommit green: 192 tests, 0 failures, credo --strict clean,
    coverage 90.36% against the 88 floor.

Independent review (Codex, 3 rounds)

Both substantive findings were reproduced before being accepted, not taken on trust.

Round 1 — application metadata is not Mix's source of truth (high). Confirmed and
fixed. The first version read Application.spec(:castle, :modules). Mix.Tasks.Compile.App
fills :modules in with Keyword.put_new_lazy/3, so a project supplying its own list in
application/0 keeps it. Adding modules: [Castle] plus a task under lib/mix/tasks/
produced a tree where the metadata read [Castle], the beam sat in ebin, and
Mix.Task.load_all/0 found the task — the old check reported clean on exactly the
hazard it existed to prevent. Replaced with the ebin scan.

Round 2 — source regex misses parenthesised definitions (medium). Confirmed and fixed.
defmodule(Mix.Tasks.Castle.Hidden) is ordinary Elixir that mix format preserves, and a
regex anchored on whitespace after defmodule never matched it; inside a :prod branch it
escaped both checks. Relaxing the regex only trades that for the opposite error, since
module-looking prose in a @moduledoc starts matching. Replaced with
Code.string_to_quoted + Macro.prewalk, with regression cases pinning both directions.

Round 3 — lexical aliases bypass the detector (high). Partly taken, partly declined.

  • Taken: literal atom names (defmodule :"Elixir.Mix.Tasks.X") are now matched, and the
    AGENTS.md claim is narrowed to the forms actually enforced.
  • Declined: resolving alias Mix.Tasks, as: Ndefmodule N.Castle.X, and failing
    closed on dynamic names. Resolving aliases means implementing alias scoping and constant
    folding inside a test — a compiler — and failing closed on dynamic defmodule would flag
    legitimate metaprogramming. The limit costs little because the two checks fail
    differently: the beam check cannot be fooled by any of those forms, since each still
    writes Elixir.Mix.Tasks.<name>.beam into ebin. What escapes both is a module named
    indirectly and compiled only in an environment mix test does not build — which is
    circumvention of an invariant stated in three places, not the accident this guards
    (someone adding lib/mix/tasks/foo.ex because it looked like the natural home).
    A case in the file pins the aliased form as a known limit, so it reads as a decision
    rather than a gap to rediscover.

The loop was stopped at round 3 under the review-loop convergence rule: three rounds of the
same class of finding — "the source detector misses naming form X" — is the signal that
static analysis of a metaprogramming language cannot be made complete, not that a defect
remains.

Deliberately not done

https://claude.ai/code/session_01RotroiBdbidiCRX3KqE3Dy

Since forecastle#24 the build-time tasks are named `castle.*` while still
being compiled into Forecastle, because the namespace follows the
vocabulary a developer thinks in rather than the package implementing it.
That leaves `Mix.Tasks.Castle.*` a namespace both projects could write
into, and Mix resolves a task by module name alone: if both ever defined
the same module, whichever `ebin` came first on the code path would win,
and nothing would say which - not the compiler, not Mix, not the release.

A convention nobody enforces is a convention that lapses, so the
containment is asserted rather than remembered, the same way the coverage
floor and the `restart_new_emulator` refusal are.

The beam check reads Castle's own `ebin` - `Mix.Project.compile_path()` -
and rejects any `Elixir.Mix.Tasks.*.beam` in it. That filename is Mix's
own discovery rule: `Mix.Task.load_all/0` scans the code path for it and
consults no application metadata at all. Scoping to Castle's `ebin`
rather than the code path is the point, since Forecastle's is on that
path during the test and its `Mix.Tasks.Castle.Relup` belongs there.

A source check covers what a beam check structurally cannot - a module
compiled only in an environment `mix test` does not build - by parsing
`lib` and matching literal `defmodule` nodes. It matches the two forms a
name is written in, an alias and a literal atom, and deliberately does
not resolve names: `alias Mix.Tasks, as: N` then `defmodule N.Castle.X`
is invisible to it, and a case pins that as a known limit rather than a
gap to rediscover. Resolving it means alias scoping and constant folding
inside a test. The beam check cannot be fooled by any of those forms, so
what escapes both is a module named indirectly *and* built only in
another environment - circumvention rather than the accident this guards.

Both assert they are looking at something before concluding it is clean,
because an empty directory filters to no tasks and reads exactly like a
clean result.

Closes #33

Claude-Session: https://claude.ai/code/session_01RotroiBdbidiCRX3KqE3Dy
@ausimian

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bbd096cd42

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread test/castle/no_mix_tasks_test.exs Outdated
`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
@ausimian

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Another round soon, please!

Reviewed commit: bea42a2fff

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@ausimian
ausimian merged commit 04d05f9 into feature/upgrade-tooling Aug 25, 2026
7 checks passed
@ausimian
ausimian deleted the issue/33-no-mix-tasks branch August 25, 2026 02:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant