Packages

Ash resources modelling sandbox lifecycle, over ex_sandbox -- templates a host `use`s to get a working sandbox registry, project, environment and credential store without writing its own Ash.Domain from scratch.

Current section

Files

Jump to
Raw

mix.exs

defmodule AshSandbox.MixProject do
use Mix.Project
@version "0.1.0"
@source_url "https://github.com/FoundryStack/ash_sandbox"
def project do
[
app: :ash_sandbox,
version: @version,
elixir: "~> 1.14",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
aliases: aliases(),
deps: deps(),
name: "AshSandbox",
description: description(),
package: package(),
docs: docs(),
source_url: @source_url,
# ⚠️ BEFORE `Mix.compilers()`, which reads backwards and is not a typo.
# `:boundary` is not a pass over already-compiled output: its `run/1`
# opens the ETS tables and installs the compiler tracer that `:elixir`
# then writes every cross-module reference into, and its `after_compiler`
# callback is what reports. MEASURED: appended after `Mix.compilers()`
# instead, the first file `:elixir` compiled raised `the table identifier
# does not refer to an existing ETS table` from
# `Boundary.Mix.CompilerState.initialize_module/1` -- the tracer fires
# before the table it writes to exists.
#
# From this app's time in the Axonn umbrella: it was the ONLY app there
# with `:boundary` here, for two findings recorded against
# `Boundary.Checker.errors/2`'s unconditional cycle detection and an
# `@opts` attribute collision with `Spark.Dsl` -- see this module's own
# `use Boundary` note below for the shape of the check itself.
compilers: compilers(Mix.env())
]
end
def application do
[
extra_applications: [:logger],
mod: {AshSandbox.Application, []}
]
end
defp description do
"Ash resources modelling sandbox lifecycle, over ex_sandbox -- templates a host " <>
"`use`s to get a working sandbox registry, project, environment and credential " <>
"store without writing its own Ash.Domain from scratch."
end
defp package do
[
name: "ash_sandbox",
licenses: ["Apache-2.0"],
links: %{
"GitHub" => @source_url,
"Changelog" => @source_url <> "/blob/main/CHANGELOG.md"
},
# `docs` and `CHANGELOG.md` ship because `docs/0`'s `extras:` names them:
# `mix hex.publish` builds the documentation from the working directory,
# but a consumer reading the tarball -- or anyone rebuilding docs from an
# unpacked release -- gets a broken `extras:` without them. `priv` is
# separately load-bearing and has its own reason: see `priv/boundary.md`.
files: ~w(lib priv docs mix.exs README.md CHANGELOG.md LICENSE)
]
end
defp docs do
[
main: "readme",
source_ref: "v#{@version}",
source_url: @source_url,
# ⚠️ Every name here is one a document has to spell out and ExDoc cannot
# resolve, which is the exact combination that turns a correct document
# into a failed build under `--warnings-as-errors`.
#
# `AshSandbox.Application` is `@moduledoc false`, and `priv/boundary.md`
# names it in backticks in the row declaring it PRIVATE -- naming it is
# the whole point of that row, and the strict parser that reads the table
# requires the backticks. `ex_sandbox` hit this first and recorded it:
# de-linking the name trades a build failure for a silent contract
# failure, which is the worse of the two.
#
# `AshSandbox.Application.start/2` is the same module, named with an
# arity from `EncryptedSecret.warm/0`'s own `@doc` -- ExDoc matches the
# bare name and the `fun/arity` form separately, so both need an entry.
#
# `AshSandbox.Internal.DataLayerSection` (and its `require_atomic/1`,
# `pre_check_with/2`) is `@moduledoc false` for the reason `AshSandbox`'s
# own moduledoc gives for the whole `Internal` namespace: documented
# without being public. The how-to and explanation pages name it to say
# exactly that, which is the same "the whole point of the reference"
# shape as `AshSandbox.Application` above.
#
# The other three do not exist at all. They were withdrawn (R-12) and the
# README, the CHANGELOG and `AshSandbox`'s own moduledoc each name them
# to say so. An entry announcing a REMOVAL has to spell the full name, so
# the cost of keeping the name exact is a line here.
skip_code_autolink_to: [
"AshSandbox.Application",
"AshSandbox.Application.start/2",
"AshSandbox.Internal.DataLayerSection",
"AshSandbox.Internal.DataLayerSection.require_atomic/1",
"AshSandbox.Internal.DataLayerSection.pre_check_with/2",
"AshSandbox.RunPolicy",
"AshSandbox.Resource",
"AshSandbox.Plug"
],
extras: [
"README.md",
"docs/getting-started.md",
"docs/how-to/choose-a-template.md",
"docs/how-to/encrypt-a-credential.md",
"docs/explanation/why-the-host-owns-the-module.md",
"priv/boundary.md",
"CHANGELOG.md",
"docs/requirement-ids.md",
"docs/provenance.md"
],
# Diataxis, and the grouping is the navigation: a reader who wants to
# *do* something and a reader who wants to *understand* something are
# looking for different pages, and one flat sidebar makes them read each
# other's.
groups_for_extras: [
Tutorial: ["docs/getting-started.md"],
"How-to": [~r{docs/how-to/}],
Reference: ["priv/boundary.md", "CHANGELOG.md"],
Explanation: [~r{docs/explanation/}, "docs/requirement-ids.md", "docs/provenance.md"]
],
# ⚠️ `AshSandbox.Internal.*` is grouped rather than hidden. Four of the
# five carry real reasoning in their moduledocs -- why refusing an
# allowlist change is the honest shape, why the owner check is a filter
# and not a bypass -- and `@moduledoc false` would delete that prose from
# the only place a reader looks for it. The group name is what says
# "private": `priv/boundary.md` is the contract, and being documented is
# not being public.
groups_for_modules: [
Interface: [AshSandbox],
Templates: [~r/^AshSandbox\.\w+Template$/],
Types: [AshSandbox.EncryptedSecret],
"Internal (private -- no compatibility promise)": [~r/^AshSandbox\.Internal\./]
]
]
end
defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]
# Ash and `ex_sandbox`, and nothing referencing Axonn (FR-002, FR-006, T005).
# `:boundary` only where it is declared -- see the dependency's own note. In
# `:prod` this is the plain default list, so a consumer that never resolves
# `:boundary` can still compile this app.
defp compilers(env) when env in [:dev, :test], do: [:boundary] ++ Mix.compilers()
defp compilers(_env), do: Mix.compilers()
defp deps do
[
{:ash, "~> 3.0"},
# ⚠️ `{:plug, "~> 1.16"}` was removed here with `AshSandbox.Plug` (R-12).
# It was declared rather than borrowed for exactly the right reason --
# the umbrella supplies `plug`, so the module compiled either way and
# would have failed inside a consumer that had no `plug` of its own. With
# the only module that used it gone, the declaration is the same kind of
# invisible-here claim in the other direction.
# Same rule as `plug` above, and the same failure mode. The templates
# emit `Ash.Policy.Authorizer` policies (003 T050), and Ash needs a SAT
# solver to evaluate them. The umbrella already has one via `axonn`, so
# this compiles and passes without the line -- then raises
# `Picosat.solve/1 is undefined` inside a consumer that has no solver of
# its own, on their first authorized read.
{:picosat_elixir, "~> 0.2"},
# ⚠️ `~> 1.2`, not `~> 1.0.1`, and the minor floor is load-bearing. 1.2.0
# adds the `address/1` callback and the loopback port publish that groups
# 5, 8 and 9 are built on; 1.0.0 additionally shipped `boundary.md` where
# `Application.app_dir/2` cannot reach it and `ExSandbox.LoopFormatter`
# in `test/`, which `package/0` does not publish --
# `library_boundary_test.exs` reads the first and two Mix tasks here name
# the second. It was briefly an absolute path dependency while 1.2.0
# waited on the maintainer's two-factor code; it published 2026-08-31 and
# the path is gone, which is what `DependencyAndGateTest` refuses to let
# regress -- a path resolves on the machine that wrote it and nowhere
# else, and this repository is worked in git worktrees.
{:ex_sandbox, "~> 1.2"},
# `enforce-the-domain-graph` names three apps for this dependency and one
# of them, `apps/sandbox_gateway`, no longer exists -- the umbrella root's
# `releases/0` note records that its live half moved under
# `Axonn.Routing`. This app is the third one there is, and it is the one
# that most wants the check: FR-002 and FR-006 say nothing here may
# reference Axonn, and until now that direction was enforced by review.
# `AshSandbox`'s boundary declares `deps: []` and `check: [apps: [axonn:
# ...]]`, so a reference to any `Axonn.*` module from this app fails the
# build rather than a reader. This app is the one place the check is
# blocked by neither of SCR-001's two findings -- it has no `Ash.Domain`
# module and no cycle -- which is why the compiler runs here and nowhere
# else.
# ⚠️ `only: [:dev, :test]`, and that is the difference between checking
# this app and taxing everyone who uses it. A `compilers:` entry is not a
# private choice: a consumer resolving `ash_sandbox` from Hex compiles it
# with this project file, so an unscoped `:boundary` would make an
# internal architecture check a hard build requirement of every
# application that depends on this one. `DependencyAndGateTest` is what
# noticed -- its `declared_deps/0` drops anything whose `only:` excludes
# `:prod` precisely because that is not consumer facing.
#
# The check loses nothing by it. `mix precommit` and `mix prepush` both
# run in `:test` and development builds are `:dev`, so every build that
# could introduce an upward reference still runs the compiler. Only a
# `:prod` build skips it, and a `:prod` build is not where the reference
# gets written.
{:boundary, "~> 0.10", runtime: false, only: [:dev, :test]},
# `only: :dev` for the same reason `:boundary` is scoped: a consumer
# resolving this package from Hex compiles it with this project file, and
# a documentation tool is not a build requirement of anything that
# depends on this library.
{:ex_doc, "~> 0.34", only: :dev, runtime: false}
]
end
# `precommit` runs `test`, and a `test` invoked from inside another command
# inherits that command's environment -- which is `dev`, where the test
# helpers are not compiled. Without this the gate fails on its own plumbing
# rather than on anything it is checking.
def cli do
[preferred_envs: [precommit: :test]]
end
defp aliases do
[
precommit: [
"compile --warnings-as-errors --force",
"deps.unlock --check-unused",
"format --check-formatted",
# ⚠️ A real gate step, not a courtesy. `mix docs --warnings-as-errors`
# rejects a broken autolink, a missing `extras:` entry and a reference
# to a hidden module -- the three ways a documentation change breaks
# without breaking a test. `ex_sandbox` learned this the expensive way:
# its first push failed on a defect that had been committed and locally
# green for two commits, because only CI ran the docs build.
#
# ⚠️ `cmd`, and via `MIX_ENV=dev`, because `ex_doc` is `only: :dev`
# while `preferred_envs` puts this whole alias in `:test`. Written as a
# plain `"docs --warnings-as-errors"` step it fails with `The task
# "docs" could not be found` -- a gate reporting a missing task rather
# than a documentation defect.
"cmd env MIX_ENV=dev mix docs --warnings-as-errors",
"test"
]
]
end
end