SvEx
Scaffolder for Elixir/Phoenix projects with a Svelte 5 UI layer.
SvEx generates a project and then keeps it upgradable: target.exs records what
the project asked for, plugin.exs records what was actually written. Every
reader here works against an explicit project root rather than the current
working directory — SvEx.Root exists for exactly that reason, and
SvEx.BoundaryGuard enforces it mechanically over lib/.
The goal document explains why the project is shaped this way. This file keeps to the mechanics — what is built, what to run, and what is installed where.
Contents
- What a plugin is — infrastructure only, and assembled in any order
- Current state — what is built, and the one command that is not
- One project, two outputs — the hex package and the
mix sv_ex.newarchive- The module tree — every module, its responsibility and its size
- Running the checks —
bin/check, and each gate on its own- Unresolved manual merges — the
TODO(sv_ex)gate, and the tagged testsbin/checkskips - CI check names — the single copy, in
.claude/04-git-flow.md
- Unresolved manual merges — the
- The hex dependency source —
--sv-ex-pathuntil the package is published - Installation — the dep line, and why it is
only: [:dev] - Acknowledgements
- Prior art — Fireside, Igniter, Sourceror
- Vendored libraries — four upstream trees, and why they are not deps
- Generated projects — where a generated UI comes from
What a plugin is
A plugin is an infrastructure concern, never a domain one — caching, a
container runtime, a secrets vault, authentication, an asset pipeline, CQRS
wiring. It knows how to install itself into a project and nothing whatsoever
about what that project is for. Its write surface is mix.exs,
application.ex, config/*.exs, router.ex, compose.yaml and its own files
under a namespace it owns, and it stops there. Why that boundary holds, and why
composition is order-independent, are G10 and G9 in the
goal document.
Declaration order in target.exs is not a build order: the resolver
topologically sorts on declared requires/provides/conflicts capabilities
and breaks ties by declaration order, purely so the output stays deterministic.
:contributes is the ownership mode that keeps that true of the shared files,
which are exactly where order would otherwise leak in — every plugin appends to
application.ex and mix.exs. A contribution is located by an idempotency key
rather than by position, so re-applying it is a no-op and the file's final
content does not depend on who got there first.
A plugin is declared in one of two ways. Most are a file list, a dep list and a
capability set — nothing that needs to run — and those may ship as a
priv/plugin/manifest.exs with no module at all, and therefore no way to
execute anything at generation time. A plugin that genuinely needs to do
something the file list cannot express implements the SvEx.Plugin.Behavior
behaviour instead and gains two optional escape hatches, transform/2 and
upgrade/3. Which form a plugin uses is visible before you install it.
Current state
The engine is built and under test; what is missing is the command that drives it end to end. The honest split today:
| Present | Not yet |
|---|---|
~5,000 lines under lib/sv_ex/ — config, manifest, plugin pipeline, rewriters | mix sv_ex.gen, the generate command |
The recorded baselines under priv/meta/ and the mix sv_ex.baseline.* tasks | Anything published to Hex |
The mix sv_ex.new archive under installer/sv_ex_new/ | |
bin/check, .github/workflows/ci.yml, lefthook hooks, version bumping | |
Governance docs (CLAUDE.md, .claude/) and their shipped twin under priv/ | |
The decisions, failure modes and numbered goals, recorded in GOAL.md |
Nothing below is aspirational about why — the design decisions are settled and
evidenced in GOAL.md. What remains is wiring the built pieces into one
command: mix sv_ex.new bootstraps a project and writes its target.exs, and
every piece that reads that file and applies plugins exists, but no single task
yet walks the whole path.
One project, two outputs
SvEx is one project. It ships two artifacts, and the split is a packaging boundary rather than an organisational one — there is no second product, no second repository and no second roadmap.
| output | built from | what it is |
|---|---|---|
| the hex package | this project root | the library a generated project depends on: SvEx.Root, SvEx.Config, SvEx.Manifest, SvEx.Plugin, SvEx.Source |
| the archive | installer/sv_ex_new/ | the mix sv_ex.new Mix archive — app :sv_ex_new, module SvExNew |
The archive has its own mix.exs for one reason: a Mix archive must ship with
zero hex requirements, and mix archive.build packages an application's
whole ebin. Built from the root, the archive would carry every SvEx module
onto the developer's global code path, where an archive-resident module
permanently shadows the real one the target project depends on. The nested build
target is what keeps the archive's payload down to the bootstrap and nothing
else. It is a boundary drawn around an artifact, not around a project.
The two artifacts meet exactly once: the archive writes a target.exs and the
package reads it back with SvEx.Config.read!/1. There is no shared encoder and
no digest pinned in two files.
One consequence is worth stating, because it is F4's exact shape: the archive's
tests are not reached by the root mix test. They only run when invoked in
their own directory:
(cd installer/sv_ex_new && mix test)
The mitigation is that the sequence below and the CI job list both name the archive build explicitly, so a step that stops running has to stop being named at the same time, and the omission is visible in the diff.
The module tree
Every module below is implemented and under test; the suite holds coverage at 100%. Line counts are given because "done" on its own has been wrong here before — this table once described the tree as empty files while every module in it was already written.
| Module | Responsibility | Lines |
|---|---|---|
SvEx.Root | the explicit project root every other reader is resolved against | 95 |
SvEx.Config | read and validate target.exs — the hand-authored intent | 493 |
SvEx.Config.Section | the shared section decoder every SvEx.Config.* module is built on | 232 |
SvEx.Config.* | one module per section: project, otp, web, api, cache, container, security, authn, features, assets, supervision | 7–75 each |
SvEx.Manifest | read and write plugin.exs — without compiling the target | 467 |
SvEx.Plugin | reads and writes manifest.exs — a plugin's own declaration | 33 |
SvEx.Plugin.Behavior | the behaviour for a plugin that needs transform/2 or upgrade/3 | 208 |
SvEx.Plugin.Derive | derives a plugin by diffing a composed tree against its baseline | 318 |
SvEx.Plugin.Apply | writes a plugin into a target, parking what will not apply | 377 |
SvEx.Plugin.Record | records what was applied, with a content hash per file | 259 |
SvEx.Plugin.Diff | the file-level diff the derive and apply paths share | 96 |
SvEx.Plugin.Classify | sorts a plugin's files into managed, seed and block modes | 67 |
SvEx.Plugin.MixChanges | the mix.exs half of a plugin's declaration | 63 |
SvEx.Source | the read-as-data codec — parses a config file without evaluating it | 164 |
SvEx.Source.MixExs | structural placement into mix.exs | 395 |
SvEx.Source.ConfigExs | structural placement into config/*.exs | 266 |
SvEx.Source.ApplicationEx | structural placement into application.ex | 138 |
SvEx.Template | renders the priv/boilerplate payload into a target | 122 |
SvEx.Hash | content hashing — canonicalises whitespace, strips comments (E4) | 105 |
SvEx.Baseline | the vanilla mix new / phx.new output a plugin diffs against | 228 |
SvEx.Clock | the wall clock, as the one module under lib/ allowed to read it | 32 |
Plus five Mix tasks under lib/mix/tasks/: sv_ex.baseline.compose,
sv_ex.baseline.record, sv_ex.plugin.derive, sv_ex.plugin.apply and
sv_ex.check.
SvEx.BoundaryGuard is deliberately not in this table. It is a check on the
source rather than a part of the tool, so it lives at
test/support/boundary_guard.ex and compiles in :test only. It is what
enforces the ambient-state ban — GOAL.md, D4.
Running the checks
bin/check is the commit gate. It runs the first five gates below across
both outputs — this project root and installer/sv_ex_new/:
bin/check
Each gate also runs on its own:
| Command | Gate |
|---|---|
mix format --check-formatted | formatting |
mix credo --strict | static analysis, complexity, and the comment-tag gate below |
mix coveralls | line coverage, minimum_coverage: 100 |
mix doctor | documentation ratio — 100% of public functions and moduledocs |
mix dialyzer | type checking (first run builds the PLT, several minutes) |
mix docs | ExDoc output into doc/ |
elixir scripts/vendor.exs check | every vendored tree matches its recorded digest |
Lefthook enforces a subset today: mix format --check-formatted and
mix credo --strict on pre-commit, mix test on pre-push, and Conventional
Commits on the message.
Unresolved manual merges
An update that cannot place a change parks it as a commented TODO(sv_ex)
block — the mechanism, its two measured limits and the package.json exception
are GOAL.md, "When a change will not apply"
and "Comment-type-based actions". What is
local to this repository is the gate: .credo.exs gives
Credo.Check.Design.TagTODO a non-zero exit_status, so a marker in any Elixir
source takes the run from exit 0 to exit 2.
mix credo --strict # exit 2 while a TODO(sv_ex) marker is outstanding
bin/check does not run the tagged tests, and a green bin/check alone is
therefore not a green suite. Three tags are excluded by default, and they are
disjoint by dependency: no test carries more than one, because ExUnit's
include filter beats its exclude filter per tag, so a doubly-tagged test would
run in an environment that cannot support it.
| tag | what it needs | how it runs |
|---|---|---|
:toolchain | the external toolchain only (mix new, phx_new) — safe anywhere | mix test --include toolchain |
:determinism | a subprocess probe re-run under a reversed atom-interning order | mix test --include determinism |
:archive | building and installing the archive from installer/sv_ex_new | mix test --include archive |
The full local sequence:
bin/check
mix test --include toolchain --include determinism --include archive
(cd installer/sv_ex_new && mix test --include toolchain)
SvEx.TagCoverageTest asserts mechanically that every tag the suite excludes is
named by an --include on a CI step, so a newly excluded tag cannot go
unnamed by every workflow step. It reads ci.yml as text, so what it proves is
that a step exists which would run the tag — never that the step ran.
CI check names
The four job names .github/workflows/ci.yml reports, and which branch each one
gates, are specified in
.claude/04-git-flow.md
under "Required check names", including how to read the strings off a real run.
That file is the single copy on purpose: a second table here drifted from it
once already. The failure it guards against is E9. The link is absolute
because .claude/ is not in package/0's files: list — a relative one
resolves in the repo and 404s from the published docs.
The hex dependency source
mix sv_ex.new splices {:sv_ex, "~> 0.1", only: [:dev], runtime: false} into
the generated project by default, and accepts --sv-ex-path to splice a path
dependency instead.
Until sv_ex is published, generation goes through --sv-ex-path, and the
{:hex, requirement} branch of the generator's dep_line/1 is not merely
uncovered but known-unreachable. It stays untested end to end on purpose even
after publication, because the honest test needs the network, and as an untagged
test it would turn bin/check red for any offline developer, for reasons
unrelated to their change.
Installation
def deps do
[
{:sv_ex, "~> 0.1", only: [:dev], runtime: false}
]
end
only: [:dev] and runtime: false are deliberate rather than incidental: SvEx
is a build-time scaffolder, and nothing it defines is needed once the generated
application is running.
To work against an unreleased checkout, depend on it by path instead — this is
what --sv-ex-path generates:
{:sv_ex, path: "../sv_ex", only: [:dev], runtime: false}
Documentation is generated with ExDoc
and published at https://hexdocs.pm/sv_ex. The vendored libraries in lib/sv_ex/vendor/
compile into this application but are filtered out of the docs by
filter_modules — their API is not ours to document.
Acknowledgements
Prior art
SvEx's central idea — that a generated project should stay upgradable, which means telling a file the generator wrote from a file a human has since edited — is not original to it.
- Fireside by Ihor Barakaiev (MIT)
is where
plugin.exsgets its shape. Thetarget.exs/plugin.exssplit — intent in one file, what was actually written in the other — is Fireside's insight applied to a generator. The files are named for what they hold rather than for where the idea came from, so the credit is recorded here in prose instead: it belongs to Fireside. - Igniter by Zach Daniel and the
Ash team (MIT) is the code-generation and project-patching framework Fireside
is built on;
GOAL.mdrecords what it gets right and where the gap is. SvEx does not depend on Igniter; the structural-placement modules (SvEx.Source.MixExs,SvEx.Source.ConfigExs,SvEx.Source.ApplicationEx) solve a narrower problem against a vendored Sourceror. - Sourceror by doorgan (Apache-2.0)
is what makes structural placement possible at all, and it is vendored here
(see below).
get_range/1pluspatch_string/2edits a range of an existing file and leaves every other byte untouched — as opposed to reprinting the AST, which reformats code the user never asked to change. Everymix.exs,config/*.exsandapplication.exedit this project makes goes through it.
Fireside depends on Igniter, and Igniter depends on Sourceror. SvEx sits at the end of that chain and owes all three.
Vendored libraries
Four libraries are compiled directly into this application from
lib/sv_ex/vendor/ rather than resolved as Hex dependencies, and re-namespaced
to SvEx.Vendor.*, because sv_ex is installed inside someone else's
project. Every requirement it declared would become a constraint on that
project's resolution, and a project using Igniter or Ash already carries its own
Sourceror pin and already defines Sourceror.
Those are two separate conflicts and each half of the treatment answers one.
Vendoring settles the version conflict; the SvEx.Vendor.* rewrite settles the
module-name one, since two definitions of one module is E2 in different
clothes. override: true solves neither — reproduced: Mix honours it only in
the top-level project and ignores it inside a dependency.
This is a separate constraint from the archive's zero Hex requirements: the archive is its own Mix project with its own (empty) dependency list, and the two ebins are disjoint, so nothing vendored here affects it either way.
Each directory holds the upstream lib/ tree and the upstream licence file,
unmodified except for recorded patches. Full provenance, checksums and patch
notes belong in lib/sv_ex/vendor/README.md.
| Library | Version | Licence | Author | Used for |
|---|---|---|---|---|
sourceror | 1.12.2 | Apache-2.0 | doorgan | structural placement — parse and patch by range |
vex | 0.9.2 | MIT | Bruce Williams | validating target.exs sections |
typedstruct | 0.5.4 | MIT | Jean-Philippe Cugnet and contributors | the config and manifest structs |
simple_enum | 1.0.0 | MIT | DarkyZ aka NotAVirus | enumerated section values |
These are other people's work carrying other people's licences. Nothing in
lib/sv_ex/vendor/ is formatted, linted or documented by this project's gates, and the
vendored modules are filtered out of the published docs — their API is theirs,
not ours.
Generated projects
Generated projects get their UI components from
sv5ui, a skin over the
bits-ui headless components, with the
sveltic theme template. The project
skeleton itself comes from Phoenix's own
phx.new.