Skip to content

Declarative scaffolding — design

Status: shipped. Split per-object TOMLs and jm remove landed in v0.13.4 (schema 6); the sacred/glue jm apply contract and jm regenerate landed in v0.14. For the user-facing walkthrough with diagrams, see ../declarative-scaffolding.md. The bundled declarative_scaffold example is the runnable proof of the end-to-end story.


Motivation

Before v0.13.4, every just-makeit command was additive and imperative:

  • object, module, method, property, function, add each mutated just-makeit.toml and generated files as a side effect.
  • There was no way to remove anything — you hand-edited the TOML and deleted files yourself.
  • There was no way to batch-scaffold a complex object — you ran one CLI call per state var, method, and property.
  • just-makeit.toml was a single monolithic file; a project with many objects had one large, merge-conflict-prone manifest.

just-makeit.toml is already a complete declarative description of the project — the CLI just writes it as a side effect. The gap is the reverse direction: author the spec, then materialize the project from it. These three features close that gap as one coherent model.


The model

A project's TOML is the desired state. Objects can live in their own files; a command materializes (apply) or tears down (remove) the generated code to match.

TOML layout

# just-makeit.toml — thin manifest. `include` is the first line: in TOML a
# key written after a table header belongs to that table.
include = ["objects/*.toml", "modules/*.toml"]

[project]
name = "doppler"
# ...and the rest of [project] as `jm new` writes it (schema, c_prefix, ...)
# modules/spectral.toml — one module
[module.spectral]
objects = ["fft", "fft2d"]
# objects/agc.toml — one object, its whole spec
[agc]
arg_type = "float _Complex"
return_type = "float _Complex"
class_name = "AGC"

[[agc.init_params]]
name = "ref_db"
type = "double"
default = "0.0"

[[agc.properties]]
name = "clip_db"
type = "double"
writable = true
field = true
  • include accepts globs and/or explicit paths, relative to the manifest.
  • An included file holds one or more top-level object sections (and may hold [[module.X.functions]] entries for module-level functions).
  • Backward compatible: no include key → the single-file behaviour, unchanged. jm new uses the split layout by default; --no-fragments keeps a single file, and jm migrate-to-fragments (or its objects-only subset, jm split-objects) moves an existing project.

_config.py — the load/save split

_config.py is the only reader/writer of project TOML. It changes in two ways:

Read (easy). load() reads the manifest, resolves include, and merges every file into the single dict that all consumers already expect. Nothing downstream changes — components(), init_params(), the templates, etc. all see one merged config.

Write (the real work). Mutating commands currently call save(), which rewrites the whole just-makeit.toml. With split files, each mutation must route back to the file that owns that object. So load() must record provenance — for every top-level section, which file it came from — and save() must write each section back to its origin file, preserving formatting.

  • A mutation to [agc] (e.g. jm method agc <name> ...) rewrites objects/agc.toml, not the manifest.
  • A new object (jm object foo) is written to a new file (objects/foo.toml) when the project uses the split layout, or appended to the manifest when it does not.
  • [project] always lives in the manifest. A [module.X] declaration is routed back to the file that declares it: the manifest, or modules/<name>.toml in the split layout jm new uses by default.

Provenance is in-memory only — never serialized.


Implementation bodies

A declarative spec should be able to carry the implementation itself — so one TOML produces a complete, buildable component with nothing to wire up by hand. --impl/impl_file (and their replace companion) are what make that possible; the mechanics, the two forms (named function vs. line range), the mutual-exclusivity rule, and the TOML-ownership-vs-sacred-.c behavior are documented for users on the top-level page, in The fragment.

The rationale worth recording here: --impl is a supported, recommended feature, not a legacy hazard. Before the sacred/glue contract shipped, the risk was splicing generated output into an existing, hand-edited file — a fragile brace-matching problem. The contract eliminates that risk entirely by construction: --impl/impl_file only ever feed the generated stub before the sacred _core.c exists, and once it exists it's the user's, full stop. That's why lifting an implementation from an external file was safe to keep (and lean into) rather than deprecate alongside the old splicer.


jm apply and jm regenerate — the sacred/glue contract

The mechanics — the per-file bucketing (glue / mixed / sacred), the CLI flags, the fragment-compose form of apply, regenerate's default splice-back behavior (gh-267) vs --discard — are documented once, for users, on the top-level page: What jm apply does and jm regenerate — the deliberate refresh. This section only keeps the design rationale that doesn't belong in a user-facing walkthrough.

  • Why apply never deletes. Keeping deletion out of apply means a reconcile is always safe to re-run — the only way to lose a generated file is the explicit jm remove. Splitting "reconcile" and "delete" into two verbs was a deliberate decision (see Decisions below), not an accident of implementation order.
  • Why regenerate exists instead of teaching apply to rebuild. apply is additive by invariant — teaching it to also tear down and rebuild a sacred file on a structural change would mean two different classes of side effect live behind one verb, and a user could no longer tell from the command name alone whether their _core.c is at risk. regenerate keeps that risk explicit and opt-in.
  • impl/impl_file overrides the sacred rule, deliberately. When a target declares one, the TOML — not the .c file — is the source of truth for that body, so apply re-asserts it even into an otherwise untouchable file. This is the one place the sacred/glue split bends: it's load-bearing for reproducibility (a project should be rebuildable from its manifest alone, a stronger guarantee than jm script, which only replays CLI history), so the exception is scoped tightly to targets that opt in.
  • The lift/splice-back behavior (gh-267) reuses apply's own machinery. jm regenerate's default preserve-by-splicing pass extracts and restores function bodies by name using the same by-name extract/restore code jm apply already uses to keep hand-patched module _ext.c glue intact — one splicer, two call sites, instead of a second implementation to keep in sync. jm add and jm remove --state opt out (discard=True) because a state change makes the old body's signature stale before the splice would even run.

jm remove

The explicit, destructive counterpart. Kept separate from apply so deletion is always deliberate, never an inferred side effect of a reconcile.

jm remove object <name>
jm remove module <name>
jm remove state <name>         --object <obj>
jm remove method <name>        --object <obj>
jm remove property <name>      --object <obj>
jm remove warning <condition>  --object <obj>
jm remove error <obj>          --object <obj>
jm remove function <name>      --module <mod>
  • Syntax mirrors the additive commands. A warning is addressed by its condition; error takes no name of its own, since an object has one.
  • object / module — delete what jm generated for it: the header directory under native/inc/<pkg>/, native/src/<x>/, its C test, symbol test and C benchmark under native/, and its .pyi, pytest, element-contract test and Python benchmark under src/<pkg>/ (under the module's package, for a module object); strip add_subdirectory / target_sources from the top CMakeLists.txt; drop the TOML section (and the object's file, if split). A hand-written hook file (*_extra.c, *_prologue.c, *_extra.cmake) is never deleted (gh-1216).
  • method / property / warning / error — drop the TOML entry and regenerate the glue (_ext.c, .pyi) and what jm derives from it: the link-check table and the element contract, each through the call that writes it on the way in (gh-1978). The sacred files are left alone: a removed method's <csym>_<name>() stays in _core.c, and a property's field or accessors stay where they were; the command's closing note says what to delete by hand.
  • state — structural: drop the entry and rebuild the object through jm regenerate.
  • function — drop the entry, delete its .c and declaration, and regenerate the module; an inline function stays in the header, with a note.
  • Safety: prompt for confirmation; --force skips the prompt. Warn explicitly when removal will delete a core.c / core.h that holds hand-written (preserved) bodies.

Migration

  • Schema 6 introduced the include key; load() resolves it whatever the schema.
  • jm new starts split; an older single-file project stays single-file until jm migrate-to-fragments (or jm split-objects, objects only) moves its sections out into objects/ / modules/ and adds the include glob.
  • jm upgrade needs no destructive step here — the feature is additive to the schema.

Decisions

Resolved 2026-05-19:

  1. Compose fragment placement — jm apply <fragment> copies the fragment into objects/ and adds it to include. The project stays self-contained; the external file is no longer needed afterwards.
  2. Reconcile-with-delete — jm apply never deletes. Deletion is strictly jm remove's job. The two commands stay split so deletion is always an explicit, deliberate act.
  3. Conflict on apply — if a fragment defines an object that already exists, apply errors — and the error names a specific remedy, e.g. "object foo already exists; run jm remove object foo first, or rename the object in the fragment." Never silently overwrite.
  4. Formatting preservation — save() must write each file back with a format-preserving TOML writer, not a plain dump, so a mutation to one object's file produces a minimal diff and never churns the manifest or sibling object files.
  5. Inline implementations — an object/method spec may carry its C body inline via impl (a TOML literal '''…''' heredoc) or by reference via impl_file ("path::funcname" or "path::N:M", the --impl semantics). The two are mutually exclusive. When either is set, the TOML owns the body and apply re-asserts it; otherwise the sacred-file rule keeps hand-written _core.c untouched.