yak(boundary-repo-as-sibling-boundary-web-repo-forbidden-tgrc): boundary: Repo as sibling boundary, web->Repo forbidden

This commit is contained in:
Willem van den Ende
2026-10-29 10:08:56 +00:00
parent 3ef38bc8fc
commit 13e3186f58
8 changed files with 163 additions and 1 deletions
+98
View File
@@ -0,0 +1,98 @@
defmodule Firehose.BoundaryTest do
use ExUnit.Case, async: true
alias Mix.Dep.Lock
# Boundary enforcement itself is a compile-time property: the pin on it is the
# warnings-fatal compile in `make verify` (and the recorded violation
# demonstration in the yak notes). These tests pin the *declarations* that
# compile gate reads, so a later edit that quietly re-opens the web -> Repo
# hole shows up as a failure here instead of only as an architectural drift.
#
# They read the `Boundary` module attribute that `use Boundary` persists. That
# is the same attribute boundary's own classifier reads
# (deps/boundary/lib/boundary/definition.ex: `Module.register_attribute(...,
# Boundary, persist: true)`), so this is the declaration as compiled, not a
# grep of the source text.
describe "boundary dependency" do
test "boundary is a direct dependency, not one inherited from ex_crap" do
assert {:boundary, requirement, opts} =
List.keyfind(Mix.Project.config()[:deps], :boundary, 0)
assert opts[:runtime] == false, "boundary is compile-time only; it must not ship at runtime"
assert Version.match?(locked_version(:boundary), requirement)
end
end
describe "a sibling Repo boundary" do
test "Firehose.Repo is its own top-level boundary with no dependencies" do
definition = boundary(Firehose.Repo)
assert definition.opts[:top_level?] == true,
"Firehose.Repo must be a sibling of Firehose/FirehoseWeb, not nested inside Firehose"
assert deps(Firehose.Repo) == [],
"the Repo is the bottom of the graph: it may depend on nothing"
end
test "Firehose may use Firehose.Repo" do
assert Firehose.Repo in deps(Firehose)
end
test "FirehoseWeb may not use Firehose.Repo" do
refute Firehose.Repo in deps(FirehoseWeb),
"web -> Repo is the hole this boundary closes; only contexts may touch the Repo"
end
end
describe "the context boundary's surface" do
test "Firehose exports everything, so the web tier can keep using context modules" do
assert boundary(Firehose).opts[:exports] == :all
end
end
# Application boot is the only thing that reaches from the context tier into
# the web tier. Making it its own top-level boundary (boundary's README
# prescribes exactly this for `mix phx.new` layouts) is what keeps the graph
# acyclic; the alternative -- FirehoseWeb in Firehose's deps -- is rejected by
# boundary with "dependency cycle found: Firehose -> FirehoseWeb -> Firehose".
describe "the application boundary" do
test "Firehose.Application depends on both tiers, and neither tier on the other's boot code" do
assert boundary(Firehose.Application).opts[:top_level?] == true
assert Enum.sort(deps(Firehose.Application)) == [Firehose, FirehoseWeb]
refute FirehoseWeb in deps(Firehose),
"Firehose depending on FirehoseWeb is the dependency cycle"
end
end
describe "enforcement wiring" do
# A compiler that is registered after :elixir looks wired but never reports
# anything, so the gate would be decoration. Position is the property.
test "the boundary compiler runs before :elixir in this environment" do
compilers = Mix.Project.config()[:compilers]
assert :boundary in compilers, "boundary compiler is not wired in #{Mix.env()}"
assert Enum.find_index(compilers, &(&1 == :boundary)) <
Enum.find_index(compilers, &(&1 == :elixir)),
"boundary must be listed before :elixir: it hooks after_compiler(:elixir, ...)"
end
end
defp boundary(module) do
assert [definition] = module.__info__(:attributes)[Boundary],
"#{inspect(module)} declares no boundary"
definition
end
defp deps(module), do: Keyword.get(boundary(module).opts, :deps, [])
defp locked_version(app) do
Lock.read()
|> Map.fetch!(app)
|> elem(2)
end
end