Coding standards¶
These standards apply to Heinzelmännchen and to every plugin scaffolded from its template. Tools enforce most of them; where a tool and this document disagree, this document wins and the tool configuration is the bug.
Each rule has a reason. If a rule gets in your way, change the rule in a pull request — don't work around it.
Principles¶
- Explicit beats magic, declared beats discovered-by-convention. Modules declare
module = Module(...); operations are decorated; services are registered by type. The kernel finds declarations; it never guesses. - One way to do a thing. One way to add a command (an operation), one way to inject (by type), one way to fail
(
HeinzelError), one way to configure (HZ_*settings). - Small, composable pieces. A module does one thing. A function does one thing. Prefer adding a module over growing one.
- Heads are views. Business logic never knows whether it is called from a terminal, an MCP client or HTTP.
- Agents are first-class users. Every operation has a docstring, typed parameters with descriptions, and a JSON-serializable result, so an agent can call it as reliably as a person.
Python¶
- Version: Python 3.12+. Use modern syntax: PEP 695 generics (
def f[T](x: T) -> T),X | None,matchwhere it reads better thanifchains. - Typing: everything in
src/is fully typed and passesmypy --strictandpyrightstrict.Anyis allowed only for free-form JSON and forwarded**kwargs, with a# noqa: ANN401stating which.castneeds a reason if it isn't obvious. No# type: ignorewithout an error code. - Configuration:
ModuleSettingssubclasses declared with@module.settings, every field with a plaintitleanddescriptionand a schema that picks the right control in Settings (SETTINGS_UX); never reados.environdirectly in modules. - Data: pydantic models for anything that crosses a boundary (operation results, settings, files).
Frozen dataclasses with
slots=Truefor internal value objects. - Errors: raise
HeinzelErrorwith a message written for the person or agent who will read it — what went wrong and what to do. When the input was wrong, "what to do" is a command line that would work, for the same key or argument the user typed (e.g.hz system config set clock.zones '["Europe/Berlin", "Asia/Tokyo"]'). Other exceptions are bugs. Catch broad exceptions only at isolation boundaries and comment why. - Side effects: no work at import time beyond declarations. No global mutable state except declared module
objects. Subprocesses take argument lists, never
shell=True. - Names: say what a thing is (
PluginService,installed()), not how it is built. Private helpers start with_; modules expose their API through__init__.pyand__all__. - Docstrings and comments: see Comments and docstrings.
- Formatting and lint:
ruff formatandruff checkwithselect = ["ALL"]. Ignored rules are listed with a reason inpyproject.toml. Line length 120.
Comments and docstrings¶
Code says what and how; docstrings say what something is for; comments say why. Each rule below is checked by a
tool where one exists, and by the critic agent in review otherwise.
| Rule | Checked by |
|---|---|
| Every public module, class, function and method has a Google-style docstring | ruff D |
| The first line is one sentence that works as help text: operations' first lines are the CLI help, the MCP tool description and the OpenAPI summary | ruff D, critic |
Raises: lists what a caller can catch, including exceptions raised by callees; Yields: says what a generator yields |
ruff DOC501, DOC402 (pydoclint) |
Args: only where names and types don't say it all; operation parameters are described with Field(description=...) instead, because every head shows it |
critic |
Returns: only when the type and summary line don't make it obvious |
critic |
| Comments explain why — a constraint, a trade-off, a link to an ADR or an upstream issue — never what the next line does. If a what comment seems needed, rename or extract instead | critic |
| No commented-out code; delete it, git remembers | ruff ERA001 |
TODOs are # TODO(owner): what and why, ideally with an issue link; they are debts, not decoration |
ruff TD |
Every noqa and type: ignore names its rule and says why: # noqa: BLE001 - isolate broken plugins |
ruff PGH, mypy ignore-without-code, critic |
| No change-log comments ("added X", "fixed Y"); that's what commits and CHANGELOG.md are for | critic |
| Markdown follows the Documentation rules: first sentence is the point, short sentences, links instead of repetition | docs-reviewer |
Architecture rules¶
These keep the modulith a modulith. See ARCHITECTURE.md and the ADRs.
| Layer | May import | Must not import |
|---|---|---|
heinzel.kernel |
stdlib, pydantic, pluggy, rich (types only) | heinzel.heads, heinzel.modules |
heinzel.modules.* |
hz (public API), its own submodules |
heinzel.heads, other modules' private _ files |
heinzel.heads.* |
heinzel.kernel, typer/rich (CLI) |
heinzel.modules (heads discover, never import) |
| plugins | hz (public API only) |
heinzel.kernel._*, heinzel.modules.*, heinzel.heads.* |
- A module talks to another module through services injected by type, never through private imports.
- An operation is a thin function: validate input via types, call a service, return a model.
- Services hold the logic and are unit-testable without any head.
render=hints are the only presentation code a module may contain.
Tests¶
We follow the test taxonomy of the Aignostics Python SDK (Martin
Fowler's solitary vs. sociable distinction). Every test carries exactly one marker; the suite refuses others.
The heinzel.testing pytest plugin enforces the markers and the timeouts below, in Heinzelmännchen and in plugins.
| Marker | What it tests | Collaborators | Network | Timeout |
|---|---|---|---|---|
unit |
One layer of one module in isolation | Mocked, except shared kernel utilities | Never | 5 s |
integration |
Interactions across layers (head → kernel → module → service) or between modules | Real objects, real file I/O, real subprocesses; external services mocked | Never | 15 s |
e2e |
Complete workflows as a user runs them (uv tool install, hz system plugin add) |
Everything real | Yes | 120 s, or longer set on the test; ≥ 5 min ⇒ also long_running |
- File names end in
_test.pyand mirrorsrc/:tests/unit/kernel/kernel_test.pytestssrc/heinzel/kernel/_kernel.py. - Arrange–act–assert, one behaviour per test, names that state the behaviour:
test_disabled_modules_and_their_submodules_are_blocked. - Assert on outcomes (return values, JSON output, files), not on implementation details.
- Coverage: ≥ 90 % branch coverage for unit + integration. New code should be fully covered; an uncovered line needs a reason.
- Tests don't read the wall clock: inject a fixed clock (
clock=on services). An assertion on elapsed time (linear time, a budget) is a performance test: it carries@pytest.mark.perfbesides its level,make check-fastandmake testskip it, andmake test-perfruns it in CI. - No sleeping, no ordering dependencies, no shared mutable fixtures. Tests run on macOS and Linux, Python 3.12–3.15.
Commits, branches and releases¶
- Conventional Commits:
type(scope): summaryin the imperative; the whole first line ≤ 72 characters (a squash merge may append(#12)). Types:feat,fix,perf,refactor,docs,test,build,ci,chore,style,revert. Breaking changes:!after the type or aBREAKING CHANGE:footer. Commitizen'sschema_patterninpyproject.tomlenforces this in the commit-msg hook,make commitsand CI. - Security fixes use the
securityscope (fix(security): ...); commits that resolve aCVE-…orGHSA-…name it in the body. Both land in the changelog's Security section. Breaking changes (!or aBREAKING CHANGE:footer) land in Breaking changes, with the footer's explanation. - Scopes are optional; Heinzelmännchen's are
kernel,cli,heads,ai,system,setup,heinzel,workflow,team,org,plugin,template,ci,docs,adr,agents,dx,security,release,deps,web,chat,graph,report, and the first-party pluginsclock,github,atlassian,google,zoom. A plugin's scopes are its own module and area names. - One logical change per commit; the body says why.
mainchanges only through pull requests (ADR 0013). They are squash-merged; the PR title must be a conventional commit and becomes the commit onmain.- Releases are pull requests too, opened by the Release workflow; merging one tags and publishes it.
- Versions follow SemVer and are derived from commits by git-cliff. While on 0.x,
featbumps the minor version, everything else the patch version.
Security¶
- No secrets in code, tests, fixtures or logs. Configuration comes from
HZ_*environment variables. - Dependencies have lower bounds and are locked in
uv.lock;make audit(pip-audit) must pass. - GitHub Actions are pinned to commit SHAs, run with least-privilege
permissions, never persist credentials, and pass zizmor. - Release artifacts carry build provenance attestations.
- Subprocesses: argument lists only, executables resolved with
shutil.which, user input never interpolated into a shell string. - See SECURITY.md for reporting vulnerabilities.
Documentation¶
- INTENT.md states why Heinzelmännchen exists; change it only with the maintainers.
- Every significant decision is an ADR in MADR format, numbered, never rewritten once accepted — superseded.
- README explains how to use Heinzelmännchen; AGENTS.md how to work on it; this file how to write code for it.
- Docs are part of the change: a pull request that changes behaviour updates the docs in the same commit.
Mobile UI¶
Every screen and overlay must remain usable at phone portrait and landscape sizes, including a software keyboard,
pinch zoom and device safe areas. New UI uses the shared AnchoredPopover for dropdowns, viewport-overlay for
modal backdrops, and lib/viewport.ts / lib/placement.ts for measured overlays. Full-height layouts use the shell's
scale-normalized visual height. Form fields on touch devices retain at least 16px text; do not disable user zoom.
Keep one scroll owner per pane, make flex children shrink with min-h-0 / min-w-0, and scroll oversized content
inside its pane. Settings rows wrap to the available pane width; native selectors must cap their intrinsic width,
including long provider labels. Exercise advanced settings as well as basic settings in the mobile sweep.
An expanded menu must not hide its own trigger or dismiss control.
mobile-standards.test.ts rejects layout-viewport heights and independent absolute dropdowns. The mobile browser
sweep discovers static router paths and settings sections automatically; add interactive flows there when introducing a
new panel, menu or control. Right-side content uses WorkspacePanels descriptors with stable ids: one selected
view, mounted state preserved while hidden, and Page available on phones; see ADR 0089. Exercise both navigation widths, chat, terminal, all settings, footer panes, safe areas,
zoom and visual-only keyboard shrink at the six phone viewports. Inspect saved screenshots as well as assertions.
Run HZ_SCREENSHOTS=/tmp/hz-mobile-proof uv run pytest tests/e2e/web_ui_test.py -q; also run the mobile test with
PLAYWRIGHT_BROWSER=webkit after installing WebKit with uv run --script scripts/web_build.py exec playwright-core install webkit.
These are browser-engine emulations; physical iPhone Safari remains a separate verification when available.