Tasks¶
Every task is rhiza-task <name>. The catalogue below is the registry the CLI generates
its own help from — rhiza-task list is the authoritative version for your repository,
and rhiza-task list --all adds the layers you do not have.
list also leaves out a bundle this repository does not use: the Docker tasks without a
<docker_folder>/Dockerfile, Paper without paper_folder, Presentation without
presentation_file, and Git LFS without a filter=lfs line in .gitattributes. They
still run by name (and skip), and list --all shows them.
The shape every task shares¶
Reading all ten make fragments back to back, every recipe turned out to have the same three parts:
flowchart LR
G["guard<br/><small>does the subject exist?</small>"] -->|absent| S([skipped])
G -->|present| P["provision<br/><small>uv run --with / uvx</small>"]
P --> A["argument vector<br/><small>long, mostly static</small>"]
A --> R([ok / failed])
That is why a guard and a skip appear all over this page: they are the model, not special cases.
Python¶
| task | needs | does |
|---|---|---|
install |
— | create the venv and sync dependencies |
test |
install |
run all tests |
test-lowest |
install |
run the tests against the oldest dependencies the manifest allows |
coverage |
install |
measure coverage and write _tests/coverage.xml |
typecheck |
install |
run ty and/or mypy (typechecker = ty \| mypy \| both) |
security |
install |
run the bandit security scan |
deps |
install |
run deptry over the contributed folders |
license |
install |
scan for copyleft licences |
docs-coverage |
install |
check docstring coverage with interrogate |
all |
fmt deps test docs-coverage security license typecheck rhiza-test |
run every gate, as CI does |
test and coverage share one argument builder, so the two cannot drift: both write
_tests/coverage.xml, _tests/coverage.json, _tests/html-coverage/ and
_tests/html-report/report.html. That fixed path is a contract — it is what book's
badge step reads and what CI uploads.
test retries once, on exit 3 only
Exit 3 is the xdist teardown race. A retry on 1, 2 or 4 would be re-running a real failure and hoping, so it never happens.
test-lowest shares that argument builder too, and adds --resolution lowest-direct: every
direct requirement resolves to the oldest version its specifier allows, so a floor like
typer>=0.15 is read back rather than merely declared. It is not a prerequisite of
all — it resolves from the manifest rather than the lock file, which is a different
question from "is this commit green" — so CI names it as its own job.
Why test-lowest runs --isolated
Asking uv for a different resolution mode makes it discard the lock file: without
--isolated the project environment is re-resolved in place, and .venv and
uv.lock are left downgraded once the gate finishes. On a runner nobody notices; on
your machine it is a gate that rewrites a tracked file and changes what every later
task runs against. The ephemeral environment costs one resolve and leaves nothing
behind.
A requirement with no lower bound has no floor
--resolution lowest-direct reads every direct requirement's specifier, so a bare
pytest in a dependency group is not "any version is fine" — it resolves to the oldest
release on PyPI, pytest 2.0.0, whose sdist no longer builds. The gate then fails
before measuring anything, naming the requirement that did it:
The fix is a floor on the named requirement, not a flag here. It applies to
[dependency-groups] and [project.optional-dependencies] as much as to
[project.dependencies], since all three are direct. This is not test-lowest being
stricter than the plain uv sync --all-extras --all-groups --resolution lowest-direct
it replaces: that resolves the same set the same way and fails on the same requirement.
What the task adds is floors for the packages it injects, so -n auto and the plugins
cannot be the thing that breaks.
Why there is no Rust or Go test-lowest
It is one of the few places the gate-parity contract does not reach, and for reasons
belonging to the other two toolchains rather than to this package. Go already resolves
this way: module resolution is minimum version selection, so go test builds against
the versions go.mod names and a floors gate would be test again. Cargo does the
opposite and offers no stable way to ask — -Z minimal-versions is nightly-only — so a
task here would be a gate that most consumers' toolchains reject.
It is a task so that setup runs
The job this replaces called uv directly, and a caller outside the task graph is a
caller outside install — so a repository whose local-setup.sh provisions a native
binary got a green test and a red floors job on identical code, reporting a
resolution failure that was really a missing dot. Anything that runs the suite
should arrive through install; that is the whole reason the hook is anchored there.
Rust¶
| task | needs | does |
|---|---|---|
install |
— | install the toolchain and fetch dependencies |
cargo-tools |
— | install the cargo subcommands the gates need |
test |
install cargo-tools |
run the test suite with nextest, then the doctests |
coverage |
install cargo-tools |
measure coverage and write _tests/coverage.xml |
typecheck |
install |
lint with clippy, warnings as errors |
security |
install cargo-tools |
scan dependencies for known advisories |
deps |
install cargo-tools |
report unused dependencies |
license |
install cargo-tools |
run the licence compliance scan |
docs-coverage |
install |
fail on any undocumented public item |
all |
fmt test docs-coverage security deps license typecheck rhiza-test |
run every gate, as CI does |
Go¶
| task | needs | does |
|---|---|---|
install |
— | install the toolchain and download dependencies |
go-tools |
— | install the Go tools the gates need |
test |
install |
run the test suite |
coverage |
install go-tools |
measure coverage and write _tests/coverage.xml |
typecheck |
install go-tools |
vet and lint (the compiler already type-checks) |
security |
install go-tools |
scan dependencies for known vulnerabilities |
deps |
install |
report dependency drift |
license |
install go-tools |
run the licence compliance scan |
docs-coverage |
install go-tools |
fail on any undocumented exported item |
all |
fmt test docs-coverage security deps license typecheck rhiza-test |
run every gate, as CI does |
Go has no coverage-floor flag, so coverage pipes a profile through gocover-cobertura
and checks the floor itself.
Quality¶
| task | needs | does |
|---|---|---|
fmt |
— | run the pre-commit hooks over all files |
semgrep |
— | run the semgrep static analysis rules |
rhiza-test |
install |
run the rhiza repository checks |
test-pyproject |
install |
run the pyproject.toml structure checks, verbosely |
todos |
— | list every TODO, FIXME and HACK comment |
complexity |
— | fail on a block above the cyclomatic-complexity ceiling |
docs-examples |
install |
check the fenced examples in the docs tree |
fmt skips without a .pre-commit-config.yaml, semgrep without a
.rhiza/semgrep.yml, and docs-examples without a docs folder — or with one holding no
fence it can check. None is a defect — see Skip is an outcome.
docs-examples doctests every pycon fence — all of one file's joined into a single
session with ELLIPSIS, so a stale output is reported at the line of the example that
printed it, and a fence flagged +RHIZA_SKIP is left out and counted as unchecked. It
parses every python fence with compile, every bash fence with bash -n — parsed,
never executed — every toml fence with tomllib and every yaml fence with a real
parser. The legacy result block is no longer run or diffed — write pycon, where the
expected output sits under the statement that produced it — and is named on the unchecked
line instead. Fences in any other language are reported as unchecked with a count, because silence there would read as full coverage. It answers the
question no other gate does: not "is there a docstring?" but "is what the documentation
claims still true?"
Its subject is the docs folder, plus the data fences — toml and yaml — in
README.md. That split exists so no fence is checked twice: README.md belongs to
pytest-rhiza's test_readme_validation under rhiza-test, which parses its code fences and
does not look at data ones. The two gates therefore divide by language rather than by file,
and README's contribution is reported on its own line so a partial look at a file is never
mistaken for a full one.
Two of those five can go unavailable on a machine that runs the gate fine otherwise, and
both say so on their own line rather than passing quietly: bash may be absent (a stock
Windows runner), and the yaml parser is provisioned rather than depended on — rhiza-task
is a published CLI, so a runtime dependency is an install cost every consumer pays on every
invocation, which two fences do not justify. In either case those fences are counted out of
the checked total, never assumed sound. toml has no such caveat: tomllib is stdlib at
this package's >=3.11 floor.
Parsing is not validation. A toml fence that parses may still name a setting that does not
exist, and a yaml fence that parses may still be an invalid workflow; checking either would
need the schema. What this closes is the narrower gap where a fence stopped being the language
it claims.
complexity is the one task in this section that is Python-only: it runs radon, so it is
registered in the python layer even though it reads like a neutral gate. It measures every
block in source_folder and fails when one scores above
complexity_max (default 15).
It is deliberately not a prerequisite of all. all is the aggregate a consumer's CI
invokes, so adding a gate to it would fail builds in repositories that changed nothing —
name complexity in a workflow step to opt in, as this repository's ci.yml does. A repo
whose blocks are larger should raise the ceiling rather than skip the gate; 15 suits a
codebase that already argues its complex blocks in comments.
rhiza-test runs the checks that used to live in a synced .rhiza/tests/ folder, now
provisioned as pytest-rhiza. Which checks
run is derived from the language layer, not configured: the neutral ones, plus
test_pyproject and test_docstrings for Python, test_cargo_toml for Rust,
test_go_module for Go.
Which pytest-rhiza runs them is configured, by
pytest_rhiza — a pinned release by default, and the
project environment's own copy when the setting is empty.
Testing extras¶
| task | needs | does |
|---|---|---|
benchmark |
install |
run the performance benchmarks |
hypothesis-test |
install |
run the property-based tests |
stress |
install |
run the stress and load tests |
No all names the three, and each is guarded on the folder convention it needs —
benchmarks/, stress/, or any test file at all — so a project without one skips rather
than fails. book is the one aggregate that does name them, which is how their reports
reach the published book.
Book¶
| task | needs | does |
|---|---|---|
book |
test benchmark stress hypothesis-test paper |
build the companion book |
book-nav |
— | check that every mkdocs nav entry resolves in the built book |
serve |
book |
build the book and serve it on port 8000 |
marimo |
install |
start the Marimo editor |
marimo-validate |
install |
check that every Marimo notebook runs |
book aggregates the report-producing gates, copies _tests/ into docs/reports/,
exports every Marimo notebook to docs/notebooks/*.html, builds the site with
zensical, and generates a coverage badge. It
skips entirely without a mkdocs.yml.
paper is a prerequisite but needs no copy step, unlike _tests/: tectonic writes the PDF
beside its source, and paper_folder (default docs/paper) is already inside docs_dir,
so the site build picks it up as an asset and mkdocs.yml only names it in nav. A
repository with no paper — or no tectonic — still builds its book, because a skipped
prerequisite does not block a dependent; only a failed one does. Under --strict that skip
becomes a failure and would block book, which is worth knowing but is not specific to
paper: benchmark and stress guard on folders most repositories do not have.
book-nav is the other side of that permissiveness, and deliberately not a prerequisite
of book. Skipping a producer is what lets a repository without tectonic still build its
book — and the cost is that mkdocs.yml can name a target the build never wrote. zensical
does not catch it: it reports No issues found for a nav entry whose page is missing and
for one whose asset is missing, so such a site builds green and publishes a 404 in its own
navigation. That is not hypothetical — it is what
https://jebel-quant.github.io/rhiza-task/paper/paper.pdf did.
So the check is a separate gate, for the ref that publishes rather than for every build.
Markdown targets are matched against both forms mkdocs may write (faq.html and
faq/index.html, per use_directory_urls); assets are matched verbatim; external targets
are left to a link checker. It skips before the book is built and when the config declares
no nav, because both are questions that are not askable rather than answers that are wrong.
Its prerequisite list is also where make's no-op stubs came from: book.mk had to declare
test:: ; @:, benchmark:: ; @:, stress:: ; @: and hypothesis-test:: ; @: so that
book could depend on gates the tests bundle may never have contributed. The runner
skips unregistered prerequisites, so all four stubs are gone.
serve uses Python's own HTTP server rather than an editor's built-in one, because the
JetBrains server refuses to serve gitignored directories and _book is one.
Dev¶
| task | needs | does |
|---|---|---|
doctor |
— | check local prerequisites |
clean |
— | remove build artifacts and stale local branches |
setup |
— | run the repository's own environment setup hook |
setup runs a repo-owned local-setup.sh at the repository root, and every layer's
install names it as a prerequisite — so it reaches local make test, CI and a
devcontainer through one insertion point rather than a step in each workflow.
It is the seam for a native binary a project needs before its gates can run: graphviz for a
docs plugin, libpq for psycopg, pandoc. rhiza's template owns every configuration file in a
managed repository, so before this there was nowhere to put that step — and the workaround
that was documented, shadowing install in local.mk, never ran. The Makefile shim forwards
the goal to this CLI, so install resolves here rather than in make, and CI invokes the CLI
directly without going through make at all.
Deliberately not a system-packages = [...] setting. graphviz is spelled the same on
apt and brew; libgl1-mesa-glx is not, and a list cannot express "download this tarball" —
which is what rhiza itself does for tectonic. The hook is a script, so platform detection
stays with the people who know which platforms they build on, and this package acquires no
apt/brew/winget logic — the rule lfs-install states.
A hook that exists but is not executable fails, with the chmod hint — a provisioning
step someone wrote and believed was running is exactly what must not pass quietly.
Windows runs the same hook, handed to sh rather than started directly, because the OS
execs no .sh — it answers %1 is not a valid Win32 application before the shebang is read.
GitHub's windows-latest runners ship git-bash, so there is an sh on PATH to hand it to,
and one hook file still covers every platform. Two consequences: the shebang is not
consulted there, so a hook that means bash should keep to POSIX shell; and a machine with no
sh at all fails naming sh, rather than naming the hook that never started. The execute bit
is not asked about on Windows either — os.access(X_OK) calls every existing file executable
there, so the question would pass vacuously.
An absent hook, by contrast, succeeds; it does not skip. That is deliberate and is the
one place in this package where "nothing happened" is not a skipped. Skip means work was
asked for and did not happen, which is why --strict promotes it to a failure. Nothing is
asked for by a repository that declares no hook — and since most declare none, skipping would
fail every --strict invocation on the common case, book --strict included, five
prerequisites down. The [INFO] no local-setup.sh line is what keeps that outcome legible.
doctor is the second procedural escape — it compares semantic versions, which used to be
an awk function inside a make recipe.
It names uv and git, and nothing else, and a miss is a failure rather than a warning.
Every other binary the package reaches for — docker, gh, git-lfs, tectonic, marp — is a
Guard on the one task that wraps it, and reports itself on that task's skipped line with
an install URL. doctor.mk also probed GNU make, which was honest while the task layer was
make; nothing here needs it, so it is not probed at all.
Template¶
| task | needs | does |
|---|---|---|
update |
— | sync the rhiza template into this repository |
The one task here that replaces a document rather than a make recipe.
jebel-quant/rhiza#1654 logs an upgrade
that took eight steps, a clone into ~/.local/share, three scripts run in sequence and four
commits — and the guide written from it says git add -A, where the upstream document says
never to. update is those steps as one command:
The ref is a setting rather than a flag, because a task body takes a Config and nothing
else. The environment layer is the spelling above; a repo pinning its next target in
pyproject.toml works too. Leave it unset to re-sync at whatever .rhiza/template.yml
already names.
It wraps, and does not reimplement. rhiza-claude's docs/headless.md settles that:
"A separate CLI would be a second implementation of sync to keep in step with the first,
and that is the one thing this project has decided not to maintain. One operation, one entry
point." So nothing here parses template.yml, resolves a bundle, walks the lock or merges a
file — sync.py, resolve_conflicts.py and stage_synced.py do, and this runs them in
their documented order and reads their documented exit codes. resolve_conflicts.py is
reached only on exit 1, which means "synced, with conflicts"; exit 2 means the sync
refused and applied nothing, so there is no half-done state to unpick.
Why it lives in this package: rhiza-task is the only piece of the ecosystem on PyPI, so
uvx rhiza-task update needs no install — and the install was the step people tripped over.
It stops before the commit, and prints the command instead:
[INFO] staged the synced files. To commit exactly that set:
[INFO] SKIP=check-managed-files git commit -m "chore: apply rhiza sync v1.8.0"
That SKIP= is not decoration. rhiza-hooks' check-managed-files refuses a commit touching
any path in the lock's files: list, and this commit is by construction exactly that list —
better seen once than hidden. A sync that resolved conflicts has just taken the template's
side of each one, which is right for a managed file and still a diff worth reading before it
becomes history.
RHIZA_CLAUDE_DIR points at an existing rhiza-claude clone — the ~/.local/share/rhiza-claude
that headless.md tells you to make. Set, it is read and never written to: a
git reset --hard into your working clone is not something a sync command should be able to
do to you. Unset, this package keeps its own shallow clone under XDG_CACHE_HOME and
refreshes that, because a stale sync.py is a correctness risk and its own copy has nothing
to lose.
This is the one task this repository cannot run against itself
rhiza-task is not rhiza-managed and has no .rhiza/ — see CLAUDE.md — so update
skips here, and CI's gates job cannot dogfood it the way it does every other gate. Its
vectors are unit-asserted and exercised against a scratch repository, but nothing in
this repository proves sync.py still takes the arguments it is passed. What would is
a job in rhiza-claude running its scripts against this front door.
Bundle-owned sections¶
The last five sections are the bundle-owned fragments. None is a gate: no all names
them and no workflow invokes them. Each is guarded on the CLI it wraps and skips when that
tool is absent, with --strict for a caller who wants the hard failure instead.
GitHub Helpers¶
| task | does |
|---|---|
view-prs |
list open pull requests |
view-issues |
list open issues |
failed-workflows |
list recent failing workflow runs |
workflow-status |
show recent runs for the release workflow |
latest-release |
show information about the latest GitHub release |
whoami |
check github auth status |
Docker¶
| task | needs | does |
|---|---|---|
docker-build |
— | build the Docker image |
docker-run |
docker-build |
run the Docker container |
docker-clean |
— | remove the Docker image |
docker-build forwards GH_PAT and UV_EXTRA_INDEX_URL to buildx as BuildKit secrets
(--secret id=gh_pat,env=GH_PAT, --secret id=uv_extra_index_url,env=UV_EXTRA_INDEX_URL)
when they are set, so a private Git dependency or private index that the bundle's
Dockerfile resolves in CI resolves locally too. Only when set: buildx fails on a secret
whose variable is missing, while the Dockerfile treats a missing mount as a no-op. They are
never passed as build arguments, which docker history would show. A Dockerfile that
declares no such mount gets a warning about an unused secret and nothing else.
Git LFS¶
| task | does |
|---|---|
lfs-install |
configure git-lfs for this repository |
lfs-pull |
download the LFS files for the current branch |
lfs-track |
list the patterns tracked by git-lfs |
lfs-status |
show the status of LFS files |
lfs-install changed behaviour on purpose
It configures the repository and reports how to install the binary, rather than downloading one. The module docstring says so, which is the convention for every deliberate behaviour change.
Paper¶
| task | does |
|---|---|
paper |
compile the LaTeX paper to PDF |
paper-clean |
remove the LaTeX build artifacts |
The engine is tectonic, not a TeX distribution's driver. One binary to install instead of a distribution plus the list of packages a document happens to cite, because tectonic resolves what the document cites from its own bundle and caches it. So the vector is the document and one flag: convergence and bibtex are the engine's own loop, and there is no interaction mode to pin because tectonic never prompts. The cost is that a cold cache needs the network, where a provisioned distribution did not.
paper-clean follows from that: tectonic has no clean subcommand, so the task is plain
Python and guards on no tool at all — the one task in this section that works on a machine
which cannot build the paper. It removes the PDF and the auxiliary files belonging to the
folder's top-level .tex documents, so a committed diagram.pdf with no diagram.tex
survives.
paper.mk preferred a file called basanos.tex — one downstream repository's paper, named
in a template every consumer synced. That is now two conventional names, main.tex and
paper.tex, then alphabetical order.
Presentation¶
| task | does |
|---|---|
presentation |
generate the HTML slides with Marp |
presentation-pdf |
generate the PDF slides with Marp |
presentation-serve |
serve the slides with Marp's live preview |
presentation reaches Marp through npx --yes — also a deliberate change, also recorded
in the module docstring.
Skip is an outcome¶
skipped is not a soft failure and not a pass. It is the third status, and it is what
makes one aggregate work across repositories with different bundles installed:
Because it is first-class, --strict can promote every skip to a failure — which is how a
consumer's CI asserts that a gate actually measured something rather than quietly finding
nothing to do.
all is not everything¶
all is the aggregate CI runs, and two registered tasks sit outside it on purpose, so a
reader can tell "skipped deliberately" from "forgotten":
semgrep— noallnames it. Upstream rhiza runs it on a weekly cadence, not per pull request.complexityanddocs-examples— adding either toallwould fail builds in repositories that changed nothing, so a repo opts in by naming it in its own CI, which is what this one does.update— it changes the repository rather than measuring it, which is the opposite of what an aggregate of gates is for. A sync insideallwould meanrhiza-task allrewriting managed files mid-run.- the bundle-owned sections — none is a gate, as above.
Anything else registered is in all for its layer.