Packages

Scaffolder for Elixir/Phoenix projects with a Svelte 5 UI layer.

Current section

Files

Jump to
sv_ex README.md
Raw

README.md

# 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](GOAL.md) 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](#what-a-plugin-is) — infrastructure only, and assembled in any order
- [Current state](#current-state) — what is built, and the one command that is not
- [One project, two outputs](#one-project-two-outputs) — the hex package and the `mix sv_ex.new` archive
- [The module tree](#the-module-tree) — every module, its responsibility and its size
- [Running the checks](#running-the-checks)`bin/check`, and each gate on its own
- [Unresolved manual merges](#unresolved-manual-merges) — the `TODO(sv_ex)` gate, and the tagged tests `bin/check` skips
- [CI check names](#ci-check-names) — the single copy, in `.claude/04-git-flow.md`
- [The hex dependency source](#the-hex-dependency-source)`--sv-ex-path` until the package is published
- [Installation](#installation) — the dep line, and why it is `only: [:dev]`
- [Acknowledgements](#acknowledgements)
- [Prior art](#prior-art) — Fireside, Igniter, Sourceror
- [Vendored libraries](#vendored-libraries) — four upstream trees, and why they are not deps
- [Generated projects](#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](GOAL.md).
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:
```sh
(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/`:
```sh
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](GOAL.md#when-a-change-will-not-apply)"
and "[Comment-type-based actions](GOAL.md#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.
```sh
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:
```sh
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`](https://github.com/wimwian-org/sv_ex/blob/HEAD/.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
```elixir
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:
```elixir
{:sv_ex, path: "../sv_ex", only: [:dev], runtime: false}
```
Documentation is generated with [ExDoc](https://github.com/elixir-lang/ex_doc)
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](https://github.com/ibarakaiev/fireside)** by Ihor Barakaiev (MIT)
is where `plugin.exs` gets its shape. The `target.exs` / `plugin.exs` split —
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](https://github.com/ash-project/igniter)** by Zach Daniel and the
Ash team (MIT) is the code-generation and project-patching framework Fireside
is built on; `GOAL.md` records 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](https://github.com/doorgan/sourceror)** by doorgan (Apache-2.0)
is what makes structural placement possible at all, and it is vendored here
(see below). `get_range/1` plus `patch_string/2` edits 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. Every `mix.exs`,
`config/*.exs` and `application.ex` edit 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`](https://github.com/doorgan/sourceror) | 1.12.2 | Apache-2.0 | doorgan | structural placement — parse and patch by range |
| [`vex`](https://github.com/CargoSense/vex) | 0.9.2 | MIT | Bruce Williams | validating `target.exs` sections |
| [`typedstruct`](https://github.com/saleyn/typedstruct) | 0.5.4 | MIT | Jean-Philippe Cugnet and contributors | the config and manifest structs |
| [`simple_enum`](https://github.com/ImNotAVirus/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](https://sv5ui.vercel.app), a skin over the
[bits-ui](https://bits-ui.com) headless components, with the
[sveltic](https://sv5ui.vercel.app/templates) theme template. The project
skeleton itself comes from [Phoenix](https://www.phoenixframework.org)'s own
`phx.new`.