Skip to content

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 as from pkg import Name
  • In-module (object <name> --module mod) — shares a .so subpackage, imported as from 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:

  1. 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.
  2. Reconcile that render into the real tree, file by file, by the kind _createonly.classify gives 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).
  3. 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

git clone https://github.com/just-buildit/just-makeit
cd just-makeit
make 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
git checkout -b feat/out-type-scalar-param
# ... make changes, commit ...
gh pr create --fill

PR rules

  • CI must be green before a PR merges: the main ruleset requires the CI passed check, which ci.yml reports 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-branch promotes 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

  1. Add or edit the template file under src/just_makeit/templates/.
  2. Update the context builder in _context/ if new placeholder keys are needed.
  3. Wire the new file into the relevant run() function (_new.py, _object.py, _init.py).
  4. A new generated file needs a row in _createonly.RULES saying who owns it; tests/test_gh949_outdated.py fails on a file with none.
  5. Add tests in tests/test_new.py or tests/test_init.py, and prove each one is armed with scripts/sabotage.py (see CLAUDE.md, "Proving a gate").
  6. 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

  1. Create a feature branch from main:

    git checkout main && git pull
    git checkout -b feat/my-feature   # or fix/, docs/, chore/
    
  2. Make your changes. Commit early and often on the branch.

  3. Push and open a PR:

    git push -u origin feat/my-feature
    gh pr create
    
  4. Wait for CI to go green on the PR. Fix any failures before merging.

  5. Arm auto-merge (gh pr merge --auto --squash); it squash-merges when CI passed is green, with no rebase needed if other PRs land first.

  6. 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.