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