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

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:

PresentNot yet
~5,000 lines under lib/sv_ex/ — config, manifest, plugin pipeline, rewritersmix sv_ex.gen, the generate command
The recorded baselines under priv/meta/ and the mix sv_ex.baseline.* tasksAnything 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.

outputbuilt fromwhat it is
the hex packagethis project rootthe library a generated project depends on: SvEx.Root, SvEx.Config, SvEx.Manifest, SvEx.Plugin, SvEx.Source
the archiveinstaller/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.

ModuleResponsibilityLines
SvEx.Rootthe explicit project root every other reader is resolved against95
SvEx.Configread and validate target.exs — the hand-authored intent493
SvEx.Config.Sectionthe shared section decoder every SvEx.Config.* module is built on232
SvEx.Config.*one module per section: project, otp, web, api, cache, container, security, authn, features, assets, supervision7–75 each
SvEx.Manifestread and write plugin.exs — without compiling the target467
SvEx.Pluginreads and writes manifest.exs — a plugin's own declaration33
SvEx.Plugin.Behaviorthe behaviour for a plugin that needs transform/2 or upgrade/3208
SvEx.Plugin.Derivederives a plugin by diffing a composed tree against its baseline318
SvEx.Plugin.Applywrites a plugin into a target, parking what will not apply377
SvEx.Plugin.Recordrecords what was applied, with a content hash per file259
SvEx.Plugin.Diffthe file-level diff the derive and apply paths share96
SvEx.Plugin.Classifysorts a plugin's files into managed, seed and block modes67
SvEx.Plugin.MixChangesthe mix.exs half of a plugin's declaration63
SvEx.Sourcethe read-as-data codec — parses a config file without evaluating it164
SvEx.Source.MixExsstructural placement into mix.exs395
SvEx.Source.ConfigExsstructural placement into config/*.exs266
SvEx.Source.ApplicationExstructural placement into application.ex138
SvEx.Templaterenders the priv/boilerplate payload into a target122
SvEx.Hashcontent hashing — canonicalises whitespace, strips comments (E4)105
SvEx.Baselinethe vanilla mix new / phx.new output a plugin diffs against228
SvEx.Clockthe wall clock, as the one module under lib/ allowed to read it32

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:

CommandGate
mix format --check-formattedformatting
mix credo --strictstatic analysis, complexity, and the comment-tag gate below
mix coverallsline coverage, minimum_coverage: 100
mix doctordocumentation ratio — 100% of public functions and moduledocs
mix dialyzertype checking (first run builds the PLT, several minutes)
mix docsExDoc output into doc/
elixir scripts/vendor.exs checkevery 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.

tagwhat it needshow it runs
:toolchainthe external toolchain only (mix new, phx_new) — safe anywheremix test --include toolchain
:determinisma subprocess probe re-run under a reversed atom-interning ordermix test --include determinism
:archivebuilding and installing the archive from installer/sv_ex_newmix 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 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.

LibraryVersionLicenceAuthorUsed for
sourceror1.12.2Apache-2.0doorganstructural placement — parse and patch by range
vex0.9.2MITBruce Williamsvalidating target.exs sections
typedstruct0.5.4MITJean-Philippe Cugnet and contributorsthe config and manifest structs
simple_enum1.0.0MITDarkyZ aka NotAVirusenumerated 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.