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).
  • For the emitter experiment: Augeas (see below). Not needed for make test or make check.

Augeas (the config-emitter experiment's oracle)

brew install augeas          # macOS
sudo apt install augeas-tools # Debian / Ubuntu
sudo apk add augeas          # Alpine

Needed only to run make -C notes/experiments/emitters, which reproduces the measurement behind ADR-031's choice of bespoke-format emitter. CI installs it (.builds/*.yml) so a frozen experiment cannot rot unnoticed.

There is no uv add route for it, deliberately. python-augeas is a binding that ships no lenses. The ~230-lens corpus is the whole reason the oracle uses Augeas, and it comes only with the system package. The binding would also need a compiler and augeas.h at install time, which would make uv sync fail for everyone over an optional dev tool. Augeas is a development and CI dependency only; it never enters the runtime path, so uv tool install punix is unaffected.

Get the code

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

uv sync resolves the dependency graph and creates .venv/. Everything comes from PyPI: there are no git sources, no [tool.uv.sources] entries, and no in-repo vendor/ copy. After that, every uv run ... command works.

The shipped wheel depends on cyclopts and tabulate and nothing else. MIXINv2 is a dev dependency ([dependency-groups] dev), used only by the dual-engine differential harness under src/punix/evaluator/ and tests/d_differential/; punix/evaluator/** is excluded from the wheel. The evaluator Punix actually ships is the hand-written punix.ir.evaluator.NativeEvaluator.

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

source .venv/bin/activate

First-pass verification

make test            # the whole suite, ~20 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)
│   ├── evaluator/         # the dual-engine boundary (native | MIXINv2)
│   ├── realise/           # sandboxed builds, recipe classes, fetcher (below)
│   │   └── recipes_lib/
│   │       └── std/       # std.cargo, std.go, std.cmake, std.autotools, ...
│   ├── deploy/            # transport, manifest, atomic switch, generators,
│   │                      #   emitters, recipe identity + the pinned loader
│   ├── migrate/           # RCL→PCL and Homebrew→PCL translators
│   └── cli/               # cyclopts entry points, one module per area
├── packages/
│   ├── official/          # 382 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
│   └── d_differential/    # native-vs-MIXINv2 agreement
├── 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 (NativeEvaluator) ══ 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 the reasoning; the diff already shows the change.

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