Current section
Files
Jump to
Current section
Files
mix.exs
defmodule SvEx.MixProject do
use Mix.Project
# `.version` is the single source of truth, bumped per commit by the lefthook
# post-commit hook (scripts/bump_version.exs). Read it, never store it here.
# `@external_resource` is what makes a bump recompile this file; without it
# the version baked into the build lags the file that defines it.
@version_path __DIR__ |> Path.join(".version")
@external_resource @version_path
@version @version_path |> File.read!() |> String.trim()
# Required by hex: `mix hex.build` exits 1 with "Missing metadata fields:
# links" without it, so this is not optional metadata.
#
# One repository for both outputs -- `installer/sv_ex_new/mix.exs` points
# here too rather than at an installer repository of its own.
@source_url "https://github.com/wimwian-org/sv_ex"
# Set by `bin/check`, which runs every gate in every target itself with
# arguments chosen per gate. Without this seam it would get the fan-out below
# on top of its own loop and run the archive's dialyzer and coveralls twice --
# minutes, in CI, for nothing. See `aliases/0`.
@no_fanout "SVEX_NO_FANOUT"
def project do
[
app: :sv_ex,
version: @version,
elixir: "~> 1.20",
aliases: aliases(),
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
# Test modules define structs of their own and implement Vex's Extract
# protocol for them. Consolidation happens before those files compile, so
# a consolidated protocol would not see them.
consolidate_protocols: Mix.env() != :test,
deps: deps(),
name: "SvEx",
description: "Scaffolder for Elixir/Phoenix projects with a Svelte 5 UI layer.",
test_coverage: [tool: ExCoveralls],
dialyzer: dialyzer(),
docs: docs(),
package: package()
]
end
# Run "mix help compile.app" to learn about applications.
def application do
[
extra_applications: [:logger]
]
end
# `preferred_cli_env` as a project key is deprecated; `cli/0` is where it
# lives from Elixir 1.15 on. Without it every coveralls task has to be
# prefixed with `MIX_ENV=test` by hand, and forgetting once reports coverage
# for a build that never ran the tests.
def cli do
[
preferred_envs: [
coveralls: :test,
"coveralls.detail": :test,
"coveralls.html": :test,
"coveralls.json": :test,
dialyzer: :dev,
docs: :dev,
doctor: :dev
]
]
end
# Vendored rather than resolved as Hex dependencies: SvEx is installed inside
# someone else's project, and every requirement it declared would constrain
# that project's resolution. They live under `lib/sv_ex/vendor/`, matching
# their `SvEx.Vendor.*` module names, so `lib` picks them up with no separate
# elixirc_paths entry. See lib/sv_ex/vendor/README.md.
# The custom Credo checks under `credo/` are compiled in :test so the suite
# can call them directly -- Credo itself loads them via `requires:` in
# .credo.exs, which runs in :dev where they are not compiled.
defp elixirc_paths(:test), do: ["lib", "credo/checks", "test/support"]
defp elixirc_paths(_env), do: ["lib"]
# `installer/sv_ex_new` is a build target of its own -- its own mix.exs, deps,
# .credo.exs, .formatter.exs and test suite. A root `mix test` compiles `lib/`
# and reaches not one line of it, which is why a root `mix coveralls` never
# reported a figure for the archive. These aliases run the root task first and
# then fan it out into every nested target.
#
# Targets are discovered from `installer/*/mix.exs` -- the same glob
# `.credo.exs` and `bin/check` use -- so adding or renaming one needs no edit
# here.
#
# HOW MIX SPLITS THE ARGUMENTS, because it decides what these can and cannot
# do: in an alias list, the entry NAMED AFTER the alias receives the command
# line's arguments and every entry after it receives NOTHING
# (`Mix.Task.run_alias/6` in Elixir's own mix). So `mix test --seed 1` seeds
# the root run and the archive runs its suite plain. That is the right way
# round rather than a limitation worked around: `mix test test/sv_ex/x_test.exs`
# names a path that exists in the root and not in the archive, so forwarding it
# would fail rather than narrow.
#
# `format` is deliberately NOT here. It is the one task whose fan-out has to
# change with the arguments, and it cannot see them: `mix format
# --check-formatted` would check the root and REFORMAT the archive, turning
# the read-only gate lefthook runs on pre-commit into a silent mutation.
# `bin/check` stays the both-targets formatter gate.
defp aliases do
[
"deps.get": fanout("deps.get"),
test: fanout("test"),
coveralls: fanout("coveralls"),
"coveralls.json": fanout("coveralls.json"),
# `--strict` because the archive's own .credo.exs sets `strict: false`, and
# the strict run is this repository's standard (bin/check, ci.yml,
# .claude/08-quality-gates.md). What the ROOT run does stays whatever was
# typed on the command line.
credo: fanout("credo", ["--strict"]),
dialyzer: fanout("dialyzer"),
doctor: fanout("doctor")
]
end
defp fanout(task, extra \\ []), do: [task, &fan_out(task, extra, &1)]
defp fan_out(task, extra, _alias_args) do
if System.get_env(@no_fanout) in [nil, ""] do
Enum.each(nested_targets(), &fan_out_to(&1, task, extra))
end
end
# `mix cmd` aborts on a non-zero exit, so a failing archive fails the root task
# rather than being reported and passed over.
defp fan_out_to(dir, task, extra) do
Mix.shell().info("==> #{dir}: mix #{Enum.join([task | extra], " ")}")
Mix.Task.run("cmd", ["--cd", dir, "mix", task | extra])
end
defp nested_targets do
"installer/*/mix.exs" |> Path.wildcard() |> Enum.map(&Path.dirname/1)
end
# Run "mix help deps" to learn about dependencies.
defp deps do
[
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false},
{:doctor, "~> 0.22", only: :dev, runtime: false},
{:ex_doc, "~> 0.34", only: :dev, runtime: false},
{:ex_machina, "~> 2.8", only: :test},
{:excoveralls, "~> 0.18", only: :test},
{:faker, "~> 0.19", only: :test},
# `lefthook.yml` runs `mix git_ops.check_message` as the commit-msg hook,
# so without this dep `lefthook install` makes every commit fail on a
# missing task. `~> 2.12` and not the `~> 2.6` the rulebook used to
# document: 2.11.4 fixed UTF-8 commit-message parsing, and this repo
# writes em dashes in commit bodies routinely.
{:git_ops, "~> 2.12", only: :dev, runtime: false}
]
end
# Vendored source lives under `lib/`, which hex ships by default -- that is a
# side benefit of the move, not the reason for it.
# There is no exclude form in hex -- `files:` is an allowlist, and hex's own
# default would ship `priv` WHOLE -- so `priv` is enumerated. `priv/meta/**`
# and `priv/baselines.exs` are deliberately absent: the three tasks that read
# them build a CWD-relative "priv/meta" path, which in a consuming project
# resolves against THAT project's root and fails whether or not the trees
# shipped. The baselines travel as content-addressed release archives
# instead -- see `mix sv_ex.baseline.record`.
#
# `priv/boilerplate` must ship: it is the payload rendered into every
# generated project, and `installer/sv_ex_new` carries no `priv/` at all, so
# the archive cannot deliver it. Without it a generated project gets no
# `.credo.exs` and the whole comment-tag gate silently never arrives.
#
# An entry matching NOTHING is a hard `mix hex.build` failure ("Missing
# files: ..."), not a no-op -- which is why there is no speculative
# `CHANGELOG*`. Add that in the commit that first writes CHANGELOG.md.
# Adding a new priv/ payload directory means adding it HERE too.
defp package do
[
licenses: ["MIT"],
links: %{"GitHub" => @source_url},
files: ~w(
lib
priv/boilerplate
priv/templates
mix.exs
.formatter.exs
.version
README.md
GOAL.md
LICENSE*
)
]
end
# The PLT lives in `_build/` and is gitignored, so a clean checkout rebuilds
# it rather than trusting a stale one. `:mix` and `:ex_unit` are added because
# both the scripts and the checks call into them.
defp dialyzer do
[
plt_add_apps: [:eex, :mix, :ex_unit],
flags: [:error_handling, :extra_return, :missing_return],
ignore_warnings: ".dialyzer_ignore.exs",
list_unused_filters: true
]
end
# Vendored modules live under `SvEx.Vendor.*` since the namespacing rewrite,
# so a `^SvEx` prefix match would now publish them. Their API is not ours to
# document, so the namespace is excluded explicitly.
defp ours?(module, _metadata) do
name = inspect(module)
String.starts_with?(name, "SvEx.") and not String.starts_with?(name, "SvEx.Vendor.")
end
defp docs do
[
main: "readme",
source_ref: "v#{@version}",
filter_modules: &ours?/2,
extras: [
"README.md",
"GOAL.md": [title: "Goal"]
],
formatters: ["html"]
]
end
end