just-makeit — Developer Guide¶
This document is the entry point for working on just-makeit itself.
It covers the project layout, how the code is organized, the test suite,
and the release process.
What is just-makeit?¶
just-makeit is a Python CLI tool that scaffolds Python C extension projects.
One command generates a complete, working project: core C library, thin Python
binding, CMake build system, and full test coverage — all passing before you
write a single line of code.
Key idea: At the C level, every "object" is the same structure:
_core.h (API + inline step()), _core.c (lifecycle + block processor),
and a CMake OBJECT library that compiles once and links into both the Python
.so and the combined C shared library.
The only thing that varies is the Python packaging layer:
- Standalone (
object <name>) — its own.so, imported asfrom pkg import Name - In-module (
object <name> --module mod) — shares a.sosubpackage, imported asfrom pkg.mod import Name
Repository layout¶
git ls-files is the tree, and the module table in the repo's CLAUDE.md
says what each file under src/just_makeit/ owns. What the listing does not
tell you:
| Path | What to know |
|---|---|
src/just_makeit/ |
The package. templates/ holds the real template files; examples/ holds every bundled example, shipped in the wheel |
src/just_makeit/scripts/ |
Shell utilities shipped in the wheel (jm-install-deps, jm-docker-e2e), unlike the repo's own scripts/ |
scripts/ |
Repo tooling, run by make targets; never shipped |
tests/ |
The pytest suite; the Makefile's PROJECT_ENV_TESTS names the files that run in the project env (see testing.md) |
docs/ |
The site source. docs/examples/ is generated by scripts/copy_examples.py and gitignored |
Makefile / standard.mk |
This repo's configuration / the vendored cross-org targets (see below); local.mk holds repo-only targets |
changelog.d/ |
Pending changelog fragments; absent while none is pending |
bootstrap.toml, docker/ |
System dependencies (make install-deps) and the container images |
install.sh |
The curl installer, published at the docs site's root by make docs |
.github/workflows/ |
See CI overview |
How the code fits together¶
Command flow¶
Every command enters at _cli.py:main() and dispatches to one module. Which
modules, grouped by what they do — just-makeit --help is the list of
record, and the module table in the repo's CLAUDE.md says what each file
owns:
just-makeit <cmd> → _cli.py:main()
scaffold new → _new · object → _object · module → _module
function → _function · view → _view · app → _app
bind → _bind (derive the binding from a C header)
extend method → _method · property → _property · add → _add
record → _recorddecl · error → _error · warning → _warning
perf → _perf · remove → _remove
reconcile apply → _apply · regenerate → _regenerate · status → _status
adopt → _adopt · upgrade → _upgrade
migrate-to-fragments → _migrate · split-objects → _split_objects
build & run build / test / dry-run → _build · bench → _bench
example → _example · ci → _ci · install-deps → _scripts
inspect script → _script · config / version → _cli itself
The apply model¶
_apply.run() is the reconciler everything else leans on, and it works the
same way every time:
- Replay the whole manifest into a temporary tree — every object,
module and function re-scaffolded from scratch by the same writers the
CLI verbs use, so the render cannot disagree with a fresh
jm new. - Reconcile that render into the real tree, file by file, by the kind
_createonly.classifygives it: jm's files are overwritten, shared ones have their marked blocks spliced, the author's only gain what is missing, and versioned ones are left alone (the user-facing table is in the edit lifecycle). - Refresh what depends on the author's own files — docstrings from the
header's Doxygen (
_docsync), the link-check tables (_linkcheck) — and run the project's formatter over what was written.
status is the same replay run against a copy of the project, followed
by a diff: whatever apply would change is what is behind. That is why the
two cannot disagree about drift, and why anything apply fails to reconcile
is also invisible to status unless it is compared explicitly (the
UNRECONCILED and OUTDATED sections exist for exactly those files).
Templates and rendering¶
Generated file content lives as real files under
src/just_makeit/templates/, one directory per kind of file (ls it).
_render.py loads each at import time and substitutes
<<placeholder>> tokens (C/H templates wrap them as /*<<token>>*/ so
clang-format can still parse the file). The context dict is built by the
make_*_ctx() functions in _context/.
To change what generated files look like: edit the relevant file
under templates/, not a Python string constant.
Config (_config.py + just-makeit.toml)¶
just-makeit.toml is the source of truth for scaffolded state: [project],
one table per object ([<comp>], usually in objects/<comp>.toml), one per
module ([module.<name>], in modules/), and the shared declarations —
[[enum]], [codec.<name>], [[app]]. The schema is
Configuration; _keys.py is what refuses a key no
table accepts.
_config.py is the only reader and writer. It merges the fragments on
load and routes each table back to its file on save, and every other
module asks it through typed accessors (components(), modules(),
module_objects(), state_vars(), enums(), …) rather than indexing the
dict.
Development setup¶
make setup syncs the dev dependency group and installs the git hook. Plain
uv sync gets you the tools (uv syncs dev by default) but not the hook, so
the hooks would run for the first time in CI.
Then run make start-here. It is a signpost rather than a summary: it links to
whichever source owns each answer, and reports what this clone still needs —
git hook, dev tools, whether standard.mk matches canonical. That last part is
the bit no document can do for you.
Then:
make test # unit + integration
make test-examples # slow: scaffolds, builds with cmake, runs
make bench # scaffold generation benchmarks
make lint # the gate CI runs — exactly this
make format # fix formatting
Run these through make, not directly. The targets are not aliases for the
obvious command: make test runs pytest in a deliberately isolated
--no-project environment so the suite exercises the installed-package path,
which a bare uv run pytest does not. The Makefile is the single source of
truth for how every tool runs; pyproject.toml owns which versions, and
.pre-commit-config.yaml dispatches back in via make -s lint-<tool>. Calling
a tool directly gets you a different environment than CI, silently.
Where the targets come from¶
make help is generated from the targets that actually exist — it is the one
list that cannot go stale, so prefer it over any list written in prose.
The shared ones live in standard.mk, vendored from the cross-org standard at
https://just-buildit.github.io/standard.mk; the Makefile holds only this
repo's configuration, and local.mk holds targets unique to it. Do not edit
standard.mk — it is vendored verbatim and make lint fails on any
difference from canonical. Per-repo variation is a variable in Makefile; a
shared change goes to canonical and comes back by re-vendoring. Usage and the
full contract are documented beside the file itself.
Git workflow¶
Every change to main is a PR: the main ruleset requires one and has no
bypass. A release is a PR too (make release-branch VERSION=X.Y.Z, see
release-checklist.md).
Branch naming¶
| Prefix | Use |
|---|---|
feat/ |
new command, flag, or generated output |
fix/ |
bug fix |
docs/ |
documentation only |
chore/ |
tooling, CI, deps, version bump |
PR rules¶
- CI must be green before a PR merges: the
mainruleset requires theCI passedcheck, whichci.ymlreports on every PR, and every review thread resolved. - Keep PRs focused — one logical change per PR makes bisect and revert easy.
- A branch that changes
src/just_makeit/adds its CHANGELOG entry as a file,changelog.d/<section>/<slug>.md(a single list item);make lint(changelog-check) refuses the branch otherwise, and its message says where the file goes.make release-branchpromotes the fragments.
Merging¶
PRs land by auto-merge. Arm it (gh pr merge --auto --squash) and
the PR squash-merges as soon as CI passed is green. The branch does not have
to be up to date with main, so independent PRs never wait on each other. The
branch is deleted on merge; history stays linear (squash or rebase, never a
merge commit).
Adding a new template / feature¶
- Add or edit the template file under
src/just_makeit/templates/. - Update the context builder in
_context/if new placeholder keys are needed. - Wire the new file into the relevant
run()function (_new.py,_object.py,_init.py). - A new generated file needs a row in
_createonly.RULESsaying who owns it;tests/test_gh949_outdated.pyfails on a file with none. - Add tests in
tests/test_new.pyortests/test_init.py, and prove each one is armed withscripts/sabotage.py(seeCLAUDE.md, "Proving a gate"). - Update the relevant page in
docs/commands/and any relevant workflow docs.
CI overview¶
The workflow files are the record — each opens with a comment on why it is
shaped the way it is. The one required check is CI passed, a job in
ci.yml that waits on every other job there and fails on any that neither
succeeded nor skipped itself.
| Workflow | Trigger | What it does |
|---|---|---|
ci.yml |
PRs, push to main, nightly, dispatch |
The test matrix, the examples (Windows clang-cl included), lint, coverage, docs, wheel, bench, the install and consumer smoke jobs, artifact smoke and the Docker image, all feeding CI passed |
changes.yml |
called by the others | Decides what a change needs: a version bump alone, a tree already tested as a merged PR, or a docs-only diff skips most of ci.yml |
release.yml |
v* tag, dispatch |
Does not re-run the matrix: requires CI passed on the tagged commit → make wheel → wheel smoke + artifact.yml (pre-publish) → PyPI → GitHub Release → Docker images |
artifact.yml |
called; after Release; dispatch | Scaffolds real projects from the installed tool, builds and tests them, verifies the installed C library: per PR (quick), before publish on the built wheel, and after it from PyPI |
docs.yml |
push to main, dispatch |
Deploy only (make docs). The strict build that gates a merge is ci.yml's docs job (make docs-check) |
docker.yml |
called by ci.yml / release.yml; dispatch |
Builds the examples image; publishes it on a push to main and on a release |
ci-image.yml |
weekly cron, dispatch, branch push | The org standard's CI toolchain image (vendored, HAS_CI_IMAGE): repins .github/ci-images.env when its packages or sources move (make help: CI-image) |
tag-release refuses a commit whose CI passed failed, and release.yml
publishes nothing CI did not certify.
Contributing¶
main is always working code. Never commit directly to main — the
ruleset refuses it anyway.
Branch workflow¶
-
Create a feature branch from
main: -
Make your changes. Commit early and often on the branch.
-
Push and open a PR:
-
Wait for CI to go green on the PR. Fix any failures before merging.
-
Arm auto-merge (
gh pr merge --auto --squash); it squash-merges whenCI passedis green, with no rebase needed if other PRs land first. -
The branch is deleted automatically on merge.
Branch naming¶
| Prefix | When to use |
|---|---|
feat/ |
New command, flag, or template feature |
fix/ |
Bug fix |
docs/ |
Docs-only changes |
chore/ |
Deps, CI config, version bumps, formatting |
Release checklist¶
See release-checklist.md for the step-by-step process.