Declarative scaffolding¶
Author your project as a TOML, jm apply it into a buildable extension —
or split an existing single-file manifest into one fragment per component
and let mutations land back in the right file. This page walks the whole
workflow end to end.
The
includekey arrived with manifest schema 6 (v0.13.4);jm newwrites the current schema. The design doc lives at developers/declarative-scaffolding.md. A runnable end-to-end demo is bundled asjust-makeit example declarative_scaffold.New to just-makeit? Install it first — see the Quickstart on the home page for the one-liner.
TL;DR¶
Reminder: install just-makeit if you haven't already.
just-makeit new demo # bare project
cd demo
just-makeit apply ../path/to/agc.toml # one TOML, including the C body
cmake -B build && cmake --build build
ctest --test-dir build # green
The agc.toml fragment carries the whole component — type, state, and
the step() body inline. jm apply copies it into objects/, registers
it via include = ["objects/*.toml"], materializes every file the spec
implies, and wires it into the top CMakeLists.txt, the package
__init__.py, and the umbrella header. From there it builds.
Three layouts¶
A just-makeit project can live in any of three shapes; they're interchangeable and the CLI never cares which one you're on.
flowchart LR
subgraph A["Monolith (default)"]
A1["just-makeit.toml<br/>[project] [module.X]<br/>[agc] [ema] [fir]"]
end
subgraph B["Split into fragments"]
B1["just-makeit.toml<br/>[project]<br/>include = ['objects/*.toml',<br/>'modules/*.toml']"]
B2["objects/agc.toml<br/>[agc]"]
B3["objects/ema.toml<br/>[ema]"]
B4["modules/filter.toml<br/>[module.filter]"]
B1 --- B2 & B3 & B4
end
subgraph C["Fragment + jm apply"]
C1["just-makeit.toml<br/>[project]"]
C2["(external) agc.toml<br/>[agc]<br/>impl = '''…'''"]
C2 -.->|jm apply| C1
end
A -.->|jm migrate-to-fragments| B
C -.->|materializes into| B
| Layout | Best when… |
|---|---|
| Monolith | small project, single author, everything fits on a page |
| Split | many components, multi-author / multi-machine, less merge churn |
| Fragment + apply | composing a new project from a manifest you (or a generator) wrote elsewhere; CI templates |
jm migrate-to-fragments migrates Monolith → Split in one command — every
[obj] moves to objects/<name>.toml and every [module.X] to
modules/<name>.toml. (jm split-objects is the objects-only subset, kept
for projects that want modules to stay inline.) jm new seeds a fresh project
on the Split layout by default; pass --no-fragments to use the legacy
single-manifest layout. jm apply <fragment> composes a Fragment into either
layout.
The fragment¶
A fragment file holds one or more top-level object sections. Set
module = "X" on an object section to place it in an existing
[module.X]; jm apply <fragment> refuses the fragment if that module is
not declared. A section can carry the C step() body inline via impl (a
TOML heredoc), and unknown {placeholder} substitutions are left alone so
literal C braces pass through untouched:
# objects/agc.toml
[agc]
arg_type = "float _Complex"
return_type = "float _Complex"
mutable = "true"
impl = """
/* {Component} — EMA power tracker + gain pass-through. */
const float mag2 = crealf(x) * crealf(x) + cimagf(x) * cimagf(x);
state->power = state->power + state->alpha * (mag2 - state->power);
return (float _Complex)(state->gain * x);
"""
[[agc.state]]
name = "alpha"
type = "float"
default = "0.05f"
[[agc.state]]
name = "power"
type = "float"
default = "0.0f"
[[agc.state]]
name = "gain"
type = "float"
default = "1.0f"
Known placeholders:
| Placeholder | Substituted with |
|---|---|
{component} |
object name, exactly as declared (agc) |
{Component} |
title-cased class name (Agc) |
{module} / {Module} |
module name / title-cased |
{arg_type} / {return_type} |
step argument and return types |
{method} |
method name (only on [[X.methods]] sections) |
{function} |
function name (only on [[module.X.functions]] sections) |
Two more keys are honoured on object and method sections:
impl_file = "path::funcname"— lift a named function's body from an existing C file (same--implsemantics as the CLI; relative to the project root).impl_file = "path::N:M"— lift linesN..M(inclusive, 1-based) from a file instead of a named function. Out-of-bounds or inverted ranges error cleanly before any side effects.replace = { "old" = "new" }— string substitutions applied after placeholder interpolation.
impl and impl_file are mutually exclusive; apply errors before any
side effects if both are set.
An object's _step body is re-injected from impl/impl_file on every
jm apply — the manifest is the source of truth, not the generated header.
Two consequences (gh-609):
- The injected body always starts with a one-line comment naming which manifest key it came from, so a generated header never looks like plain hand-written C when it's actually a build product.
- If the on-disk body differs from what the manifest currently says, and it
isn't the untouched fresh-scaffold
/* TODO: implement */stub,applyprints a warning before overwriting it — whether the divergence came from hand-editing the generated header directly (edit the manifest instead) or from a deliberate change toimpl/impl_filethat just hasn't been applied yet.
C symbols carry [project] c_prefix (default the package name;
jm new --no-c-prefix turns it off), so the snippets below spell them
<pkg>_<comp>_…: buf_destroy is <pkg>_buf_destroy. File and CMake
target names keep the plain component name (buf_core.c, buf_core).
Custom create() and reset() bodies¶
When the generated field-assignment code is not enough — parameter
validation, lookup tables, computed masks — add create_impl and/or
reset_impl to the object section:
[lfsr]
arg_type = "void"
return_type = "uint8_t"
mutable = "true"
no_step = "true" # suppress default step/steps when using custom methods
create_impl = """
if (initial_state == 0) { free(obj); return NULL; }
obj->initial_state = initial_state;
obj->state = initial_state;
obj->mask = (length == 64) ? ~0ULL : ((1ULL << length) - 1);
"""
reset_impl = """
state->state = state->initial_state;
"""
[[lfsr.state]]
name = "initial_state"
type = "uint64_t"
default = "0"
# ... and the other [[lfsr.state]] rows (state, mask, length)
TOML ordering: keys before sub-table arrays
All scalar keys (impl, create_impl, reset_impl, arg_type, …)
must appear before any [[comp.state]] (or [[comp.methods]])
entries in the same section. TOML requires this: once an array-of-tables
header appears, all subsequent bare keys are parsed as part of that entry,
not the parent section.
Correct — keys first, state arrays after:
Wrong — key after array-of-tables header (ignored; jm apply warns
unknown state key `create_impl` — it is an object key):
obj vs state in create_impl
Inside a create_impl body the freshly calloc'd struct pointer is
named obj (not state). This avoids a C compiler redeclaration
error when a state field happens to be named state:
/* create_impl sees: */
<pkg>_lfsr_state_t *obj = calloc(1, sizeof(*obj));
/* parameters are state field names, e.g. uint64_t initial_state */
Inside a reset_impl body the pointer is the function parameter
state (as in every other C function that takes a
<pkg>_<comp>_state_t *state).
File-reference variants are also supported:
create_impl_file = "legacy/lfsr_core.c::lfsr_create"
reset_impl_file = "legacy/lfsr_core.c::lfsr_reset"
create_impl / create_impl_file are mutually exclusive, as are
reset_impl / reset_impl_file.
A reset_impl may keep a field on purpose -- a delay line keeps its
length -- so jm stops claiming what reset() restores: the generated C and
Python reset tests only call it, and the .pyi docstring leaves out its
"Reset restores defaults" example (gh-1882). Assert what your reset keeps
and clears in a test of your own.
Custom destroy() body — destroy_impl¶
Objects that allocate auxiliary resources in create_impl (heap buffers,
file handles, child objects) need matching teardown. destroy_impl splices
a body into <pkg>_<comp>_destroy() before the trailing free(state) that
releases the struct itself:
[buf]
arg_type = "void"
return_type = "void"
mutable = "true"
destroy_impl = """
if (state->log) fclose(state->log);
free(state->scratch);
"""
[[buf.state]]
name = "n"
type = "uint32_t"
default = "0"
…generates:
void
<pkg>_buf_destroy(<pkg>_buf_state_t *state)
{
if (state->log) fclose(state->log);
free(state->scratch);
free(state);
}
Use state->field (the function parameter is named state). Do not
write free(state) yourself — it is appended automatically.
destroy_impl / destroy_impl_file are mutually exclusive. The same TOML
ordering rule applies: place the scalar key before any [[buf.state]]
arrays.
Naming and failing the destructor — [<obj>.destroy]¶
Two things the generated teardown used to hardcode: it was always called
destroy(), and it was always void — so a close that is genuinely part of
the work (a writer patching a header field and appending trailing metadata
after the last sample) had no way to tell the caller it failed. One table
covers both:
[wfm_writer.destroy]
name = "close" # Python method name; default "destroy"
aliases = ["destroy"] # extra names bound to the same C function
returns = "int" # non-zero rc raises
error = "OSError" # exception class (default RuntimeError)
error_message = "failed to finalise the capture"
exit = "flush" # __exit__ calls this method instead (see below)
Manifest-only — there is no CLI flag (six interacting keys is not a CLI
shape; package set the same precedent). Edit the TOML and run jm apply.
exit — when finalize and free are separable¶
By default __exit__ runs the teardown, which releases the object. For a C
API that splits finalize from free — flush and detach, but keep the struct
— that is the wrong half, and it discards the object at exactly the moment its
results become valid:
exit names an already-declared method to call instead:
[[dp_tlm_capture.methods]]
name = "close"
fn = "dp_tlm_capture_close"
status_return = true
error = "ValueError"
error_message = "the capture has a hole: records were dropped"
[dp_tlm_capture.destroy]
exit = "close"
Now __exit__ calls close(), leaves the handle intact, and the natural
shape works — run the block, then read what you captured. tp_dealloc still
calls destroy and still swallows its status, so the memory is freed exactly
once whether or not the with block ran.
It names a method rather than a bare C symbol on purpose: the finalizer
already carries fn, status_return, error and error_message, so
__exit__ inherits all four and the explicit cap.close() cannot disagree
with the implicit one about whether a failure raises. A second C-symbol slot
would have re-created the very split gh-541 closed.
The teardown inherits too. close and destroy are two routes to one
condition — the finalizer latches the verdict, the destructor reports the same
hole at collection — so when [<obj>.destroy] states no error or
error_message of its own, both are taken from the named finalizer. Without
that, the minimal declaration above would raise ValueError: the capture has a hole from __exit__ and RuntimeError: dp_tlm_capture_destroy reported failure from the GC path, for the same hole.
Each key is inherited on its own (gh-864): declaring error alone keeps the
finalizer's error_message, and vice versa, so a teardown that genuinely
needs to say something different still can.
The docstrings follow the call on both faces — the runtime __doc__ and
the .pyi both say the object is finalized and stays usable. That matters
because __enter__/__exit__ are 100% jm-owned: a project that hand-patched
the behaviour would keep jm's prose, re-transplanted at every apply, and a
doc-parity gate compares the two faces against each other — so both carrying
the same wrong sentence stays green.
returns = "int" changes the sacred core signature to
int <pkg>_wfm_writer_destroy(<pkg>_wfm_writer_state_t *state) in both _core.h and
_core.c. A freshly scaffolded component gets that from the template; an
already-scaffolded one is patched in place by jm apply, which also gives the
stub body a return 0; — but only when the body has no return yet, so a
destructor you have already written is never touched.
Where a failure surfaces¶
| Path | On non-zero rc |
|---|---|
close() (and every alias) |
raises the declared exception |
__exit__ |
raises — so a failing close propagates out of the with block |
tp_dealloc (garbage collection) |
swallowed |
The __exit__ row is the point of the feature. This is the case that used to
corrupt data in silence:
The tp_dealloc swallow is deliberate, not an oversight. CPython runs
tp_dealloc during refcount collapse: there is no caller to raise to, and an
exception may already be in flight that must not be clobbered. Discarding the
status there is the only correct choice, and the generated C says so in a
comment. If you need to know whether teardown succeeded, call close()
explicitly or use a with block — letting the object fall out of scope
cannot report anything.
Idempotence is preserved: the handle is cleared before the status is
reported, so a second close() is a no-op returning None, never a double
free — even when the first one raised.
The .pyi stub declares the real method name, every alias, and the exception
each can raise, so a type checker accepts w.close().
Opaque state fields — pointers and handles¶
Heap buffers, file handles, FFTW plans, and other resources whose C type
isn't a numeric scalar are declared with opaque = true on a
[[<comp>.state]] entry. The field is emitted into the state struct
verbatim with no auto-generated getter/setter, no constructor
parameter, no kwlist entry, and no reset assignment — Python sees
nothing of it. Lifecycle is entirely yours via create_impl (mandatory)
and destroy_impl (strongly recommended).
[fft]
arg_type = "void"
return_type = "void"
no_state = "true" # no scalar state, only opaque fields
create_impl = """
const int n = 1024;
obj->scratch = fftwf_malloc(sizeof(float _Complex) * n);
if (!obj->scratch) { free(obj); return NULL; }
obj->plan = fftwf_plan_dft_1d(n, obj->scratch, obj->scratch,
FFTW_FORWARD, FFTW_ESTIMATE);
"""
destroy_impl = """
if (state->plan) fftwf_destroy_plan(state->plan);
fftwf_free(state->scratch);
"""
[[fft.state]]
name = "scratch"
type = "float _Complex *"
opaque = true
[[fft.state]]
name = "plan"
type = "fftwf_plan"
opaque = true
Generates a struct like:
…and a constructor + destructor that run your create_impl / destroy_impl
bodies verbatim.
Opaque fields require create_impl
jm apply <fragment.toml> refuses a fragment that declares an opaque
state field without create_impl or create_impl_file. A plain
jm apply of a fragment already under objects/ does not check
this. create() zero-fills with calloc, so an uninitialized opaque
pointer is NULL and the first dereference crashes. Pair every opaque
field with a create_impl that initializes it, and a destroy_impl
that releases it (the validator does not enforce destroy_impl
because some opaque fields are borrowed and shouldn't be freed, but
most should be).
Opaque fields are TOML-only — there is no --state name:opaque:type CLI
syntax. The type string can be anything the compiler accepts (raw
pointers, typedef'd handles, function-pointer typedefs); the just-makeit
type system doesn't inspect it.
See the delay_line bundled example: a circular delay whose ring buffer
is sized at construction, using create_impl, reset_impl and
destroy_impl together:
Pitfalls and idioms¶
Opaque fields put the user in charge of ownership. The five footguns below come up most often when teaching this feature; each is paired with the idiom that avoids it.
Don't reach for opaque when a fixed-length array works
If the buffer size is a compile-time constant, declare it as
type = "float[N]" instead. Fixed-length arrays live inside
the state struct — one calloc covers everything, no separate
free, no lifetime to manage, no validator constraints. Opaque
is for storage whose size or type isn't known until construction.
Always pair create_impl with destroy_impl for owned pointers
jm apply <fragment.toml> enforces create_impl (otherwise the
pointer is NULL), but it does not enforce destroy_impl
— because some opaque fields are borrowed and must not be freed.
For every opaque field you malloc/calloc/fftw_malloc/open/
etc., add the matching teardown.
Unwind partial allocations on create_impl failure
<pkg>_<comp>_destroy() is not called when <pkg>_<comp>_create() returns
NULL, so any successful allocations made before a later failure
must be freed inside create_impl itself. The pattern is
"alloc — check — alloc — check, freeing all prior on each
failure path."
Borrowed pointers: opaque without destroy_impl is correct
When the opaque field stores a pointer that another object
owns — a shared lookup table, a parent context, a const
function pointer — destroy_impl must not free it. Declare
the opaque field, set it in create_impl, and leave teardown
alone. Document the ownership in a TOML comment so the next
reader knows it's deliberate.
Scalar setters don't realloc opaque buffers
A scalar field like length gets an auto-generated
<pkg>_<comp>_set_length() that just writes to the struct. If the
opaque buffer was sized using that scalar, calling
set_length(N_NEW) will not resize the buffer — subsequent
reads/writes overflow or under-utilize. If the field genuinely
needs to resize at runtime, expose a custom method that
reallocs the buffer and updates the scalar atomically; if it
doesn't, document the field as construction-only: jm has no key to
drop a scalar field's set_<name>. delay_line keeps set_length
and preserves length across reset() with reset_impl.
[[delay.methods]]
name = "resize"
arg_type = "void"
return_type = "void"
impl = """
float *new_taps = realloc(state->taps, n * sizeof(float));
if (!new_taps) return; /* keep old buffer on failure */
if (n > state->length) {
memset(new_taps + state->length, 0,
(n - state->length) * sizeof(float));
}
state->taps = new_taps;
state->length = n;
state->idx = state->idx % n;
"""
[[delay.methods.params]]
name = "n"
type = "uint32_t"
Integrating hand-written C libraries (c_deps, no_generate, depends_on)¶
These three keys are for projects that mix just-makeit-managed objects with existing C code that you wrote by hand or pulled in as a submodule.
c_deps — pure-C dependency subdirectories¶
jm apply emits an add_subdirectory(native/src/<dep>) block for each
entry, prepended before all component and module blocks so that CMake
sees the target definitions before any target_sources(… TARGET_OBJECTS:<dep>_core) that references them. jm new --c-dep <dep> writes the same
line when it creates the project, so a new project is in sync before any
component exists.
No Python scaffolding is generated for c_deps entries — they are C-only
libraries. Create their CMakeLists.txt by hand; jm apply only wires
them into the root CMake file.
no_generate — hand-written modules¶
[module.hand_rolled]
no_generate = "true"
no_generate_reason = "hand-written binding (gh-NNN would let jm generate it)"
jm apply emits add_subdirectory(native/src/hand_rolled) in the root
CMakeLists.txt but skips every scaffolding step — no _ext.c, no Python
test, no type stub, no __init__.py entry. Use this when the module's
Python binding is hand-written and must not be touched by the generator.
Say why with no_generate_reason (gh-1313): jm status --check fails with
UNEXPLAINED OPT-OUT on a no_generate module without one, and on a reason
left behind after no_generate is removed.
depends_on — transitive OBJECT library dependencies¶
When jm apply creates the CMake entry for fir (with
depends_on = ["resamp", "fft"] in its fragment — depends_on is set in
TOML; there is no --depends-on CLI flag), it prepends:
target_sources(<pkg>_lib PRIVATE $<TARGET_OBJECTS:resamp_core>)
target_sources(<pkg>_lib_static PRIVATE $<TARGET_OBJECTS:resamp_core>)
target_sources(<pkg>_lib PRIVATE $<TARGET_OBJECTS:fft_core>)
target_sources(<pkg>_lib_static PRIVATE $<TARGET_OBJECTS:fft_core>)
target_sources(<pkg>_lib PRIVATE $<TARGET_OBJECTS:fir_core>)
target_sources(<pkg>_lib_static PRIVATE $<TARGET_OBJECTS:fir_core>)
These put the dependency's objects into lib<pkg> (shared and static). For
a standalone object they do not reach its .so; write
{name = "resamp", link = true} to link resamp_core into it (gh-225). A
module object already links every declared core.
A depends_on entry may be a table instead of a bare name.
{name, link = true} links the dependency into the .so (gh-225), and
{name, test_only = true} links it into the component's C test and
benchmark only (gh-537). jm follows the dependency graph itself (gh-280), so
list only direct dependencies.
Since 0.15.3, depends_on also auto-includes each dependency's header in
the dependent's <comp>_core.h — "if jm links it, it includes it":
/* fir_core.h, generated */
#include "demo/clib_common.h"
#include "demo/resamp/resamp_core.h"
#include "demo/fft/fft_core.h"
So an opaque field of a dependency's type — e.g. demo_resamp_state_t *resamp; —
compiles with no manual #include. The include is generated for fresh objects
and injected idempotently into existing headers on jm apply.
Full example¶
[project]
name = "my_dsp"
c_deps = ["resamp"] # hand-written C; add_subdirectory only
[module.legacy]
no_generate = "true" # existing Python binding; don't touch
no_generate_reason = "hand-written binding, not migrated yet"
[fir]
arg_type = "float _Complex"
depends_on = [{name = "resamp", link = true}] # fir.so also links resamp_core
What jm apply does¶
flowchart TD
PRJ["**Project**<br/>just-makeit.toml + (optional) objects/*.toml"]
FRAG["**Fragment**<br/>e.g. /path/to/agc.toml<br/>(optional positional arg)"]
PRJ --> COMPOSE
FRAG --> COMPOSE["**Compose** (when given)<br/>copy fragment → objects/<br/>add include = ['objects/*.toml']"]
COMPOSE --> LOAD["**Load merged cfg**<br/>resolve include globs<br/>merge all fragments"]
LOAD --> REPLAY["**Replay in temp dir**<br/>new → module → object →<br/>method → property → function<br/>with impl_body from impl/impl_file"]
REPLAY --> SYNC["**Sync to real project**"]
SYNC --> NEW["copy every<br/>missing file"]
SYNC --> AGG["reconcile aggregate<br/>wiring files"]
AGG --> CMAKE["top CMakeLists.txt<br/>(sentinel splice;<br/>user content outside survives)"]
AGG --> UMB["umbrella native/inc/<pkg>/<pkg>.h"]
AGG --> PKGINIT["src/<pkg>/__init__.py<br/>(splice missing imports)"]
AGG --> MODFILES["src/<pkg>/<mod>/__init__.py<br/>(merge; user wrappers survive)"]
The sacred/glue contract¶
apply is the half of the contract that reconciles the manifest with
the tree. Every file is one of five kinds — see
Who owns each file. In
short: _ext.c, the .pyi and a component CMakeLists.txt are rewritten
every time. In _core.h the struct and inline step() are yours, but the
prototypes are updated by name (gh-632). _core.c is never re-rendered, but
apply appends a stub for a newly declared method (gh-1294) and changes
destroy to return int when returns = "int" (gh-541).
So editing the manifest always propagates to the glue and the _core.h
declarations. But the struct and inline step() stay sacred. If you change a
signature in TOML or add a state field, that's structural — the
glue and declarations update on apply, but the sacred _core.c body is
left as you wrote it. Rebuild it from the manifest with jm regenerate (or
jm add, which regenerates with --discard behaviour: keep bodies in
impl/create_impl, or git stash first). A new method or computed
property is additive instead: jm method / jm property, or a TOML row plus
jm apply, inject a declaration and append a stub.
Other properties:
- Idempotent. Re-running on a complete project is a no-op.
- Reproducible. A
just-makeit.toml+ your hand-written*_core.c/*_core.h(and anyc_deps/no_generatesources) fully describe a project;applymaterializes the rest. - Never deletes.
applyonly adds or refreshes files; removing a component isjm remove's job, and wiping a component back to its manifest state isjm regenerate's. - Aggregate safety. The top
CMakeLists.txtpreserves content outside jm's marked blocks (Components, Modules, External deps, Install, Libraries); module__init__.pykeeps any wrapper classes you added below the re-exports. - Bench retrofit.
applyalso appends a missingbench_<comp>_coreCMake target to any component'sCMakeLists.txt— existing projects gain C benchmark targets without a manual edit.
--only=NAME — single-component reconciliation¶
Restricts wiring regeneration to the named component: only fir's
_ext.c, CMakeLists.txt, .pyi, and test file are touched. All
aggregate files (__init__.py, root CMakeLists.txt, umbrella header)
are still updated. Useful on large projects where a full re-apply is slow.
NAME may also be a module, which reconciles that module and all its
objects.
jm regenerate <component> — the deliberate refresh¶
apply preserves the sacred _core.c body, which is exactly what you want
99% of the time. When you instead want a component rebuilt cleanly from its
manifest — after a sweeping signature change, or to discard an experiment —
jm regenerate is the other half of the contract:
It deletes every file the component owns and re-runs jm apply to rebuild
them from the manifest. The just-makeit.toml is left untouched (unlike
jm remove, which also strips the component from the manifest). Works for
both standalone and module objects.
A single confirmation guards the destructive step; --force skips it:
By default, regenerate doesn't throw your hand-written code away: before
deleting the sacred files it lifts create/destroy/reset/step()/getter/
setter/method bodies out by function name, and splices them back into the
freshly regenerated ones (gh-267) — the same by-name extract/restore
machinery jm apply already uses to preserve hand-patched module _ext.c
glue. Pass --discard to skip this and get a truly clean reset instead:
The splice is best-effort — git stash first regardless
Lifting bodies by function name is text matching, not a guarantee. A
changed signature (e.g. a new parameter) means the fresh stub wins for
that function instead of your old body. git stash (or commit) before
regenerating so you can recover the original if the splice doesn't
reproduce what you wanted.
Load and save — provenance routing¶
load() merges the manifest with every included fragment into one dict
that every consumer already expects. save() re-derives provenance from
disk and routes each section back to the file that owns it:
flowchart LR
M["just-makeit.toml<br/>[project] [module.dsp]<br/>include = ['objects/*.toml']"]
F1["objects/agc.toml<br/>[agc]"]
F2["objects/ema.toml<br/>[ema]"]
M -- load() --> CFG
F1 -- load() --> CFG
F2 -- load() --> CFG
CFG["**Merged cfg**<br/>(one dict)"]
CFG --> CMD["any mutating command<br/>jm method / property /<br/>add / remove / …"]
CMD --> SAVE["**save()** re-derives<br/>provenance from disk"]
SAVE -- "[project] / [module.X]" --> M
SAVE -- "[agc] mutation" --> F1
SAVE -- "[ema] mutation" --> F2
SAVE -- "new [nco]" --> F3["objects/nco.toml<br/>(brand new)"]
Properties:
[project]always lives in the manifest. A[module.X]declaration is routed back to the file that declares it: the manifest, ormodules/<name>.tomlin the split layoutjm newuses by default.- A mutation to
[agc]rewritesobjects/agc.toml— the manifest and sibling fragments are byte-for-byte unchanged. - A new object on a split-layout project gets a brand-new
objects/<name>.toml. - An emptied fragment (
jm removeof its last section) is deleted.
Single-file projects (no include key) are unaffected: save() writes
the whole cfg back to the manifest exactly as before.
Migrating an existing project¶
Reminder: install just-makeit if you haven't already.
That's it. Every [obj] section moves out of just-makeit.toml into
objects/<obj>.toml and every [module.X] into modules/<name>.toml;
[project] stays; the manifest gains
include = ["objects/*.toml", "modules/*.toml"]. The merged cfg every
just-makeit consumer sees is byte-identical before and after. Idempotent
— running on an already-migrated project is a no-op. Mutations afterwards
(jm add, jm method, …) save back into the owning fragment, not the
manifest.
jm split-objectsis the objects-only subset — it leaves[module.X]sections inline in the manifest. Prefermigrate-to-fragmentsunless you specifically want modules to stay put. New projects use this layout by default; pass--no-fragmentsto use the legacy single-manifest layout instead.
See it work¶
Reminder: install just-makeit if you haven't already.
The bundled example authors a complete AGC component (states, types,
inline step() body with {Component} interpolation) in one fragment,
runs jm apply, builds + ctests the result, and round-trips a separate
legacy project through split-objects. The agc*.so assertion at the
end means a silently-skipped target would fail loudly, not pass green.
See also¶
- developers/declarative-scaffolding.md — the design doc behind this feature
jm apply,jm regenerateandjm removereference- Workflows — the imperative CLI flow these commands sit alongside