Declarative scaffolding — design¶
Status: shipped. Split per-object TOMLs and jm remove landed in
v0.13.5 (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¶
Today every just-makeit command is additive and imperative:
object,module,method,property,function,addeach mutatejust-makeit.tomland generate files as a side effect.- There is no way to remove anything — you hand-edit the TOML and delete files yourself.
- There is no way to batch-scaffold a complex object — you run one CLI call per state var, method, and property.
just-makeit.tomlis a single monolithic file; a project with many objects has 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
[project]
name = "doppler"
schema = "7"
[module.spectral]
objects = ["fft", "fft2d"]
# Pull object specs in from their own files.
include = ["objects/*.toml"]
# 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
includeaccepts 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
includekey → today's single-file behaviour, unchanged. The split layout is opt-in.
_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 ... --object agc) rewritesobjects/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]and[module.X]declarations always live in the manifest.
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
applynever deletes. Keeping deletion out ofapplymeans a reconcile is always safe to re-run — the only way to lose a generated file is the explicitjm remove. Splitting "reconcile" and "delete" into two verbs was a deliberate decision (see Decisions below), not an accident of implementation order. - Why
regenerateexists instead of teachingapplyto rebuild.applyis 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.cis at risk.regeneratekeeps that risk explicit and opt-in. impl/impl_fileoverrides the sacred rule, deliberately. When a target declares one, the TOML — not the.cfile — is the source of truth for that body, soapplyre-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 thanjm 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 codejm applyalready uses to keep hand-patched module_ext.cglue intact — one splicer, two call sites, instead of a second implementation to keep in sync.jm addandjm remove --stateopt 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 method <name> --object <obj>
jm remove property <name> --object <obj>
jm remove function <name> --module <mod>
- Syntax mirrors the additive commands.
- object / module — delete the generated
native/inc/<x>/,native/src/<x>/,src/<pkg>/<x>/; stripadd_subdirectory/target_sourcesfrom the topCMakeLists.txt; drop the TOML section (and the object's file, if split). - method / property / function / state — drop the TOML entry and
re-run the existing regeneration for the affected
ext.c/core.h/.pyi. - Safety: prompt for confirmation;
--forceskips the prompt. Warn explicitly when removal will delete acore.c/core.hthat holds hand-written (preserved) bodies.
Migration¶
- New schema version (6) gates the
includekey. - A project stays single-file until it opts in. A future
jm split-objectshelper (orjm apply --split) could move inline[object]sections out intoobjects/<name>.tomland add theincludeglob. jm upgradeneeds no destructive step here — the feature is additive to the schema.
Decisions¶
Resolved 2026-05-19:
- Compose fragment placement —
jm apply <fragment>copies the fragment intoobjects/and adds it toinclude. The project stays self-contained; the external file is no longer needed afterwards. - Reconcile-with-delete —
jm applynever deletes. Deletion is strictlyjm remove's job. The two commands stay split so deletion is always an explicit, deliberate act. - Conflict on apply — if a fragment defines an object that already
exists,
applyerrors — and the error names a specific remedy, e.g. "objectfooalready exists; runjm remove object foofirst, or rename the object in the fragment." Never silently overwrite. - 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. - Inline implementations — an object/method spec may carry its C
body inline via
impl(a TOML literal'''…'''heredoc) or by reference viaimpl_file("path::funcname"or"path::N:M", the--implsemantics). The two are mutually exclusive. When either is set, the TOML owns the body andapplyre-asserts it; otherwise the sacred-file rule keeps hand-written_core.cuntouched.