GgenIgniter

An Elixir bootstrap of ggen's ontology-to-code pipeline: load an RDF ontology, run one or more named SPARQL queries against it, render the results through an EEx template, and write the result to disk with the same write-safety guards (idempotent no-op detection, unless_exists, skip_if) as the real Rust ggen's ggen-engine/src/write.rs.

Pipeline: Ontology.load!/1 -> Engine.run/2 (once per --query) -> Render.render/2 -> Actuate.write_file!/3 (or inject_content!/5 / eval_code!/2), tracked by a reconciliation manifest so a rename/removal upstream in the ontology is mechanically detected instead of silently orphaning a file.

Why this exists

Hand-maintained Elixir modules drift from the domain model that's supposed to describe them. ggen_igniter keeps generated code (Ash resources, Spark DSL extensions, or any other EEx-rendered Elixir) synchronized with an RDF/Turtle ontology as that ontology evolves — including destructive evolution (a rename, a removal) — with reconciliation memory: "what did I last generate here, and did the ontology's shape change since," rather than a stateless one-shot templater.

What each layer owns

LayerReal role in this repoStatus
ggenSemantic compilation (ontology → query → render → actuate). Fulfilled by this repo's own from-scratch Elixir port of the real Rust ggen's pipeline shape (render uses Elixir stdlib EEx, not a Tera/Liquid port) — not a runtime dependency on, or shell-out to, the real ggen binary. One real Rust component is embedded: a Rustler NIF wrapping ggen's own oxigraph query engine (the default --engine).IMPLEMENTED
IgniterCLI-task plumbing (Igniter.Mix.Task, add_notice/2) for both mix ggen_igniter.sync/.doctor. A real, unconditional mix.exs dependency. Does not use Igniter's AST-mutation API (Igniter.Project.Module/Igniter.Code/Sourceror.Zipper) anywhere — that's real, disclosed future work.IMPLEMENTED (CLI plumbing); AST-mutation PLANNED
ReactorCoordination/dependency-ordering/concurrency/compensation. GgenIgniter.Reactors.ReconcileReactor is a plain use Reactor module (explicitly not Ash.Reactor, so this stays usable without Ash). Real, tested — but opt-in, not the default, via config :ggen_igniter, use_reactor: true.PARTIAL_ALIVE (real, opt-in)
AshOptional, consumer-side only. Neither :ash nor :ash_phoenix appears anywhere in this project's own mix.exs deps. mix ggen_igniter.doctor only scans a consumer's project for use Ash.Domain as one diagnostic among several.Not a core dependency, by design

See docs/architecture/overview.md for the full ownership table (including OTP/Controller/Manifest/Receipt) and docs/glossary.md for term definitions.

What's implemented vs. planned (headline items — full detail in docs/status.md)

See docs/status.md for the complete, sourced capability table and docs/architecture/adr/ for the accepted design decisions behind these.

How do I run it

mix deps.get
mix ggen_igniter.doctor # sanity-check the environment first
mix ggen_igniter.sync --ontology path.ttl --query name=path.rq --template path.eex --out path.ex

Full walkthroughs (real commands, real output, run in this repo): see docs/tutorials/getting-started.md.

Installation

Add ggen_igniter to your mix.exs dependencies:

def deps do
[
{:ggen_igniter, "~> 26.8.27"}
]
end

Requires Elixir ~> 1.17 and OTP >= 25 (mix ggen_igniter.doctor's check 1 verifies both) plus a working Rust/cargo toolchain — the default --engine oxigraph compiles a native Rustler NIF (native/ggen_graph_nif) as part of compiling this library at all, regardless of which --engine is ever actually used at runtime.

Usage

mix ggen_igniter.sync

mix ggen_igniter.sync --ontology path.ttl --query name=path.rq (repeatable) --template path.eex --out path.ex

Each --query name=path.rq result is bound in the EEx template under name as the full list of result rows (string-keyed maps). A query whose result has exactly one row also has that row's own columns merged directly into the top-level bindings, atom-keyed, so a single-row query like spec can be referenced as bare module_name/package_name instead of hd(spec)["module_name"].

Engines: --engine oxigraph / --engine sparql / --engine qlever

--engine oxigraph (the real default since v26.8.27 — confirmed by lib/mix/tasks/ggen_igniter.sync.ex's own opts[:engine] || "oxigraph" and by an actual run's notice line) runs every query in-process against the loaded %RDF.Graph{} via a real native oxigraph engine (a Rustler NIF over ggen's ggen-graph-wasmOxigraphEngine). It became the default to fix a real, empirically confirmed ORDER BY row-reversal bug in the sparql hex package (v0.3.12) — see docs/architecture/adr/0001-oxigraph-default-query-engine.md for the full history and disclosed trade-offs (a Rust/cargo toolchain is required to compile this library at all, regardless of which engine is used at runtime). No extra required option — it works directly off the already-loaded graph:

mix ggen_igniter.sync \
--ontology test/fixtures/audit_trail_ontology.ttl \
--query spec=test/fixtures/spec.rq \
--query sections=test/fixtures/sections.rq \
--query entities=test/fixtures/entities.rq \
--query fields=test/fixtures/fields.rq \
--template test/fixtures/extension.ex.eex \
--out tmp_out/probe.ex

--engine sparql runs every query in-process too, via the pure-Elixir sparql hex package instead of oxigraph — still fully supported, and useful to A/B a result or when a query shape trips the sparql hex package's own known FILTER NOT EXISTS/UNION limitation:

mix ggen_igniter.sync \
--engine sparql \
--ontology test/fixtures/audit_trail_ontology.ttl \
--query spec=test/fixtures/spec.rq \
--query sections=test/fixtures/sections.rq \
--query entities=test/fixtures/entities.rq \
--query fields=test/fixtures/fields.rq \
--template test/fixtures/extension.ex.eex \
--out tmp_out/probe.ex

--engine qlever runs every query instead against a real, already-running QLever SPARQL endpoint (real HTTP, no in-process SPARQL evaluation). --ontology is still read as a %RDF.Graph{}, but only to look up the gnoa:Qlever-typed store resource named by --store-id — the query text itself never touches this graph's data, it runs on the remote QLever store. --store-id is required with this engine:

mix ggen_igniter.sync \
--engine qlever \
--ontology config/gno/test/store.ttl \
--store-id http://example.com/Qlever \
--query spec=priv/ggen/some-pack/gates/010.rq \
--template priv/ggen/some-pack/templates/out.ex.eex \
--out lib/generated.ex

Multi-row fan-out (--for-each NAME)

Mirroring the real Rust ggen's for_each: frontmatter field, pass --for-each NAME where NAME is one of the named --query results to render the template once per row of that query, with that row's own columns merged into the top-level bindings. Because there is no longer one static output path, --out is itself rendered through GgenIgniter.Render.render/2 using each row's bindings, so it must be an EEx-renderable path template:

mix ggen_igniter.sync \
--ontology test/fixtures/for_each_ontology.ttl \
--query modules=test/fixtures/modules.rq \
--for-each modules \
--template test/fixtures/for_each_module.ex.eex \
--out "lib/generated/<%= module_name %>.ex"

With three rows in the modules query result, this writes three separate files, one per row.

--dry-run

Pass --dry-run to preview what would be written (or skipped) without touching disk — each planned action is printed as planned: write <path> or planned: skip <path> (...) instead of being executed.

The --pack convention

Given priv/ggen/<pack-name>/{ontology.ttl,gates/*.rq,templates/extension.ex.eex}:

mix ggen_igniter.sync --pack adr-index-pack --out docs/architecture/adr/README.md

(adr-index-pack is this repo's own real pack, at priv/ggen/adr-index-pack/ — it's what regenerates docs/architecture/adr/README.md itself.)

--ontology/--query/--template are all still overridable explicitly; an explicit flag always wins over the pack-derived default. --pack-dir DIR uses DIR directly instead of resolving priv/ggen/<pack>/. Packs can also be fetched from a marketplace source (github:owner/repo[@ref] or hex:name[@version]) via GgenIgniter.Pack.fetch_pack!/2.

mix ggen_igniter.doctor

mix ggen_igniter.doctor [--pack NAME | --pack-dir DIR] [--engine sparql|qlever] [--store-id ID] [--hex-check] [--fix]

Runs a fixed checklist of 17 real checks (no fabricated pass output) — Elixir/OTP version, dependency wiring, the sparql version advisory, the :igniter/:sourceror/:dcatr/ash_domains consumer-project fix rules (--fix-able), pack shape (only with --pack/--pack-dir), git status, the native oxigraph NIF's build freshness and a real functional smoke test, an optional hex-publish readiness check (--hex-check), and a version-policy check against CHANGELOG.md. Exits non-zero only if any check comes back :error. See docs/reference/cli/doctor.md for the full, numbered list of all 17 checks and exactly which ones --fix can repair, or docs/operations/debugging.md for a practical triage playbook.

Testing

mix e2e

mix e2e (alias for mix run test/e2e/run_e2e.exs) is a real, sequential end-to-end lifecycle test, kept separate from the default mix test suite because it needs real network access and takes several minutes: it scaffolds a genuine, throwaway Ash + Phoenix application in a temp directory via real mix archive.install hex phx_new/mix archive.install hex igniter_new/ mix igniter.new subprocess calls (hex/GitHub fetches, no mocking), adds ggen_igniter itself as a path: dependency, and runs mix deps.get.

Against that one scaffolded app it then drives a multi-stage lifecycle with real mix ggen_igniter.sync subprocess invocations against the test/fixtures/ash-lifecycle-pack/ fixture pack — resource creation, attribute addition, a belongs_to/has_many relationship, a custom action, an AshPhoenix.Form create/update/validate/submit round-trip, a real mix ash_phoenix.gen.live-generated Phoenix LiveView, and finally an attribute rename — running mix compile --warnings-as-errors/mix test inside the scaffolded app after each stage.

Run it with:

mix e2e

It requires network access (hex/GitHub fetches via mix archive.install), runs for several minutes, and uses real disk space in a temp directory (cleaned up via on_exit when the test finishes). It is not part of the default mix test suite — there is no .github/workflows CI config in this repo, so it is not run by CI either; run it manually when you need to verify the full lifecycle.

What it proves: that ggen_igniter-generated code stays consistent across a real Ash resource, its AshPhoenix.Form, and its Phoenix LiveView through a realistic edit lifecycle, including a rename.

mix e2e wall-clock baseline (real, cited)

Two real mix e2e runs captured this session (scratchpad e2e_run.log, e2e_output2.log) both failed at Stage 5 (the scoped mix test test/ggen_igniter_form_lifecycle_test.exs subprocess — an AshPhoenix.Form.submit/2 assertion issue unrelated to timing) after 281.5s and 299.0s respectively (ExUnit's own "Finished in ..." line). Both runs therefore measure real wall-clock cost for Stages 0–5 only (14 of the 20 real mix/subprocess invocations lifecycle_test.ex makes end to end), not the full 8-stage lifecycle.

Neither log carries per-subprocess timestamps (each stage's stdout is merged into one cmd!/3 capture with no per-line clock), so the per-subprocess breakdown below is a structural estimate — call-count and known Elixir/Mix cost class, not measured — from reading test/e2e/lifecycle_test.ex and test/e2e/support/e2e_case.ex directly:

StageReal subprocess callsCost class
0 (scaffold + dep wiring)archive.install hex phx_new, archive.install hex igniter_new, igniter.new ... --install ash,ash_phoenix --with phx.new (runs 3 installers), deps.get, compile!, test! = 6Heaviest stage by far — igniter.new alone triggers 3 chained installers (Ash, AshPhoenix, Phoenix) each doing their own codegen + potential fresh mix.exs deps, and deps.get does a cold Hex/Git dependency resolution across the whole tree (the :igniter/:sourceror/:gno/:dcatr relaxations exist precisely because this resolution is fragile — see (c) below)
1 (sync x2 + compile + test)ggen_igniter.sync x2, compile!, test! = 4Each sync is itself an Igniter.Mix.Task subprocess (its own BEAM boot); compile!/test! are incremental after Stage 0's cold compile, so cheaper than Stage 0's
2 (sync x1 + compile + test)3Same class as Stage 1, one fewer sync call
3–4 (pure assertions)0Free — no subprocess, checks Stage 2's already-generated files
5 (Form round-trip, scoped mix test)1Where both captured real runs actually failed/stopped
6 (ash_phoenix.gen.live + compile x2 + scoped mix test)4ash_phoenix.gen.live is a real Igniter-driven LiveView generator subprocess; two compile! calls bracket the hand-edited router
7 (sync + expected-failing compile)2The compile! here is expected to raise (rename leaves a stale ticket.assignee reference)
Total (full 8-stage run)20Only 14 of these were exercised by the two captured real runs

Conclusion for the in-memory-vs-subprocess architecture decision (not attempted here — out of scope per this task): Stage 0 is the dominant cost by call count and by known Mix/Hex cost class (3 chained Igniter installers plus one cold deps.get resolution, none of which touch ggen_igniter's own sync/render logic at all). Stages 1–2 and 7's sync!/compile!/ test! calls are the ones actually exercising ggen_igniter behavior, and compile!/test! there run against an already-scaffolded app, i.e. the part of the pipeline most amenable to swapping in an in-memory Igniter.Test.test_project/1 if a scaffold's on-disk state can be faked convincingly enough for Ash to compile against it — Stage 0 itself (real phx.new/ash/ash_phoenix installers + real deps.get) is the part most resistant to that swap, since it is exactly the real, on-disk, network-resolved dependency graph this suite exists to catch drift in (see (c) below).

Reconciliation manifest: --on-stale refuse|prune|preserve

Every real mode: file, non-inject: write is recorded in a manifest at <manifest-dir>/.ggen_igniter/manifest.json, keyed by the (template, --out-template) "recipe" pair — not by ontology path or pack name alone, so editing an ontology's content in place across syncs is correctly recognized as the same ongoing recipe. Before writing, a run's real new output-path set is compared against the manifest's existing entry for that recipe: stale = old_paths - new_paths — the mechanical signature of a rename or removal upstream in the ontology.

A true no-op re-run (identical output-path-plus-content-hash set) does not rewrite the manifest file at all — not even its timestamp. See docs/reference/reconciliation/ for the full manifest/stale-artifact/destructive-evolution reference, and docs/tutorials/first-reconciliation.md for a real, run-through-in-this-repo walkthrough including a real refused run and a real prune.

Known, disclosed scope limit: this closes the orphan-file gap only for the recipe's own tracked outputs. There is no cross-file stale-reference repair — a rename that breaks separately hand-generated code elsewhere (e.g. a Phoenix LiveView dot-accessing a now-renamed Ash attribute) is not detected or repaired by this pipeline. See docs/operations/failure-recovery.md.

The Reactor path (opt-in, not the default)

GgenIgniter.Reactors.ReconcileReactor is a real, tested Reactor-based coordinator (plain Reactor, not Ash.Reactor) implementing a fuller pipeline than the default: observe prior manifest → load ontology → resolve pack → run queries → render into a real [%PendingActuation{}] plan → admit (fail-closed, whole-plan invariants) → actuate (concurrent, self-healing) → verify (mix compile --warnings-as-errors) → finalize evidence (a GgenIgniter.Receipt persisted before manifest promotion, on every path — success or failure). It supports multi-target :targets fan-out and real Reactor undo/4 rollback when :verify fails after :actuate already wrote files.

There is no CLI flag for this — set config :ggen_igniter, use_reactor: true in the consuming project (default false). With the flag left at its default, both real call sites (Mix.Tasks.GgenIgniter.Sync and GgenIgniter.Controller) are byte-for-byte unchanged from before this pipeline existed. See docs/tutorials/reactor-path.md for a real success receipt and a real compensated-failure receipt, and docs/reference/reactor/ for the full mechanism.

Documentation

Known Limitations

  1. Frontmatter inject: true splice — real and wired (README correction, verified 2026-08-27). A prior version of this document stated that mix ggen_igniter.sync had no call to Actuate.inject_content!/5. A real grep -n "inject_content!\|:inject" lib/mix/tasks/ggen_igniter.sync.ex finds a real inject_spec = if mode == :file and (frontmatter_field(frontmatter, :inject) || false) do resolve_injection!(frontmatter) end branch and a real Actuate.inject_content!(...) call site, and mix test test/ggen_igniter_sync_inject_test.exs passes 9 tests, 0 failures. Setting inject: true (plus exactly one of before:/after:/ at_line:) in a template's frontmatter really splices the rendered body into the resolved --out path's existing content — see docs/reference/cli/sync.md's inject: true section for the full anchor-resolution and idempotency rules. This is a marker-based line splice, not an AST-based structural patch — see docs/architecture/adr/0006-marker-based-injection-not-ast-patch.md.

  2. test/e2e/run_e2e.exs exit-code masking — CLOSED. The script starts ExUnit.start(timeout: 300_000, autorun: false), runs the suite via a real %{failures: failures} = ExUnit.run(), and calls System.halt/1 keyed directly off that real failures count (System.halt(1) / System.halt(0)) — verified by reading the file's actual 13 lines directly; autorun: false is present and ExUnit.run/0's return value, not a fixed exit code, drives the halt.

  3. Scaffolded-consumer dependency conflicts (igniter/sourceror/gno/dcatr/ash_domains) — CLOSED. All four fix functions this gap depends on are real, present, and actually invoked in test/e2e/support/e2e_case.ex and test/e2e/lifecycle_test.ex: relax_scaffolded_igniter_dep!/1 and relax_scaffolded_sourceror_dep!/1 strip the scaffolded app's own generated only: [:dev, :test] restrictions so they no longer diverge from ggen_igniter's unrestricted :igniter/:sourceror requirements; add_dcatr_env_config!/1 writes config :dcatr, env: Mix.env() into the scaffolded app's own config/config.exs (required because Mix never loads a path dependency's own config); and add_ash_domains_config!/3 registers the pack's SupportDesk.Support domain module in config :support_desk, ash_domains: [...]. All four are called from add_ggen_igniter_dep!/1 (itself called from lifecycle_test.ex Stage 0) or directly from lifecycle_test.ex line 102, before the first real mix compile.

License

MIT — see LICENSE.