Skip to content

Contributing

Practical setup for working on Punix itself — environment, layout, daily commands, debugging.

Prerequisites

  • Python 3.13 or 3.14. CI matrix tests both; earlier versions are unsupported.
  • uv ≥ 0.5. curl -LsSf https://astral.sh/uv/install.sh | sh or brew install uv.
  • git, make, clang/gcc. Standard on macOS, install on Linux via the distro package manager.
  • For e2e SSH tests: ssh + rsync (any modern OpenSSH).
  • For corpus work: clone of homebrew-core under sandbox/ (only needed if you'll be running the brew translator).

Get the code

git clone https://github.com/fermigier/punix-v2.git
cd punix-v2
uv sync

uv sync resolves the dependency graph (including the pinned MIXINv2 git source — Atry/MIXINv2, pinned by rev in pyproject.toml; there is no in-repo vendor/ copy) and creates .venv/. After that, every uv run ... command works.

Optional: activate the venv so you can drop the uv run prefix.

source .venv/bin/activate

First-pass verification

make test            # whole test suite — should be 583+ passed in ~10 s
make check           # lint + format + type-check

If make test is green, you have a working dev env.

Repo layout

.
├── src/punix/             # the implementation
│   ├── frontend/          # parser, type checker, lowerer (above the seam)
│   ├── ir/                # IR + canonical-hash + provenance (the seam)
│   ├── realise/           # sandboxed builds, recipe classes, fetcher (below)
│   │   └── recipes_lib/
│   │       └── std/       # std.cargo, std.go, std.cmake, std.autotools, ...
│   ├── deploy/            # transport, manifest, atomic switch, generators
│   ├── migrate/           # RCL→PCL and Homebrew→PCL translators
│   └── cli.py             # cyclopts entry points
├── packages/
│   ├── official/          # 365 working recipes
│   ├── seeds/             # bootstrap recipes (rustc, go, swift)
│   └── experimental/      # translator scratch + needs-rework
├── tests/
│   ├── a_unit/            # fast pure-logic
│   ├── b_integration/     # real I/O, no subprocess
│   └── c_e2e/             # full CLI workflows + conformance
├── tools/                 # status-report, migrate-brew, coverage scripts
├── notes/                 # design docs (read these before changing core code)
├── docs/                  # the doc site you're reading
└── zensical.toml          # docs site config

The notes/ directory holds the normative spec (01-theory.md06-review-and-v2-alignment.md). Before changing anything in src/punix/frontend/ or src/punix/ir/, read at least 01-theory.md (the inheritance calculus + the feature-decision procedure) and 04-design.md §12 (target layout). Most architectural mistakes start with "I'll just add this small thing" in the wrong layer.

The eval/realise seam

Punix's whole design is organised around one boundary:

FRONTEND (new) → IC CORE (vendored MIXINv2) ══ SEAM ══ REALISE (reused old Punix) → DEPLOY/SERVICES
  • Above the seam: pure config. Typed, decidable, terminating, no effects.
  • At the seam: IR + canonical-derivation hash. Provenance flows through.
  • Below the seam: every host-bound effect (build, transport, service lifecycle).

A PR that puts removal, IFD, build-cycle dependencies, secrets-as-values, or generations into the pure layer is rejected by theorem (see feature-procedure). When in doubt, classify the feature before writing the code.

Daily commands

# Run a subset of tests
uv run pytest -m unit                              # by marker
uv run pytest tests/a_unit/frontend/                # one package (a_unit mirrors src/punix)
uv run pytest tests/a_unit/frontend/test_parser.py::test_one -v    # one test, verbose

# Type-check + lint
make check          # ruff + ty + pyrefly + mypy
make format         # auto-fix what's fixable

# Build the docs locally
make docs           # static build
make docs-serve     # live-reload on http://localhost:8000

# Build the package corpus (or one recipe)
make dogfood        # everything in packages/official/ + seeds/
uv run punix build /tmp/sandbox --only foo,bar     # specific recipes

The CLI from inside the repo

uv run punix --help
uv run punix check stack.pcl
uv run punix build /path/to/packages-tree
uv run punix service deploy MyStack --file stack.pcl

Useful flags during development:

  • --verbose on subcommands surfaces internal step traces.
  • PUNIX_PACKAGES=/path/to/tree env var is the canonical override for the packages-tree discovery; less typing than --file for repeated calls.
  • PUNIX_BOOTSTRAP_MODE=source rejects any prebuilt-binary shortcut (the seeds/ std.binary recipes). Use this when you want to validate that everything builds from source.

Debugging a failing build

# 1. Find the build log
ls ~/.punix/punix-build-logs/<package>.log

# 2. Inspect the build script the recipe class generated
#    (each build writes to a temp dir under /var/folders/ or /tmp/punix-build-XXX/;
#     normally cleaned up on success, sticks around on failure)
ls /var/folders/*/punix-build-* 2>/dev/null    # macOS
ls /tmp/punix-build-* 2>/dev/null               # Linux

# 3. Re-run the script manually inside the build dir
cd /var/folders/.../punix-build-XXX/source
bash -x /tmp/tmpXXXXXX.sh    # the script path is in the log

The build sandbox is just a temp dir + an output dir + the recipe-class-generated shell script. Once you cd into the build dir, you can iterate on the script, the configure flags, the env — same shell, same tools.

Code conventions

  • str at dataclass/JSON boundaries; Path internally. Frontend / config-facing surfaces take strings (PCL writes strings); internal logic uses pathlib.Path.
  • Errors:
  • Above the seam (frontend / check): fail-fast, located, never silent. Every error has a FILE:LINE: error: [E#] message shape.
  • Below the seam (transport / realise / backend): wrap-and-reraise at boundaries. TransportError, SourceFetchError, etc.
  • Type hints on every public function. Dataclasses over dicts. Functional core, imperative shell.
  • from __future__ import annotations is a required first import in every module.
  • Project Python playbooks live in local-notes/playbooks/python/ (coding-guidelines.md, testing.md, CHECKLISTS.md).

Commit conventions

Conventional Commits-ish:

feat(realise): std.python_venv recipe class + pip bootstrap
fix(migrate): autotools --with-X flags use ${DEP:X} not brew-bridge
docs(status): per-topic corpus status report + script
chore(coverage): mark 4 more Linux-only deps as darwin-noop

The first line is a one-liner that survives in git log --oneline. The body explains why, not what (the diff shows what).

When to write a stage / what counts as done

Each stage ends with a one-paragraph entry in notes/08-status.md. The Definition of Done is:

  1. Code lands.
  2. Conformance tests for the property the stage adds are green (tests/c_e2e/test_conformance_stage<N>.py).
  3. Docs are updated — at minimum, the relevant docs/concepts/, docs/cli/, and docs/status/what-works.md entries.
  4. STATUS paragraph in notes/08-status.md summarising what shipped + what's deferred to the next stage.

A regression in any shipped conformance property blocks the release. There's no "we'll fix this in a follow-up."