AGENTS.md · git:20261006.3b10aa6 · 2026-10-06 · sha256 77655ab7a2d339e2
AGENTS.md git:20261006.3b10aa6A
Immutable. This exact content never changes and is served at /api/v1/blob/77655ab7a2d339e2.
# PartCAD ## Overview This repository contains all open source software that forms the PartCAD ecosystem. It ships **one** Python distribution, `partcad`, plus a `partcad-cli` compatibility shim; everything else here is an editor extension, a CAD addon, or documentation. ### The packages, all inside the one wheel * [src/partcad](./src/partcad/AGENTS.md): The core logic that enables maintaining digital thread for manufacturable physical products. * [src/partcad_cli](./src/partcad_cli/AGENTS.md): The CLI interface to most of `partcad` functionality — the `pc` and `partcad` commands. * [src/partcad_service_json_rpc](./src/partcad_service_json_rpc/AGENTS.md): A JSON-RPC service (`partcad-json-rpc` executable) exposing `partcad` functionality with methods that mirror the CLI. By default it runs a per-workspace background **daemon** (served over a socket / Windows named pipe); it can also serve over stdin/stdout or HTTP. It is the backend for `ide/vscode`, for `cad/freecad`, and for most `pc` commands, and the CLI manages it via `pc daemon start`/`stop`. The daemon owns the warm PartCAD context **and** the sandboxed Python runtimes that CAD wrappers execute in, so a client need not have a CAD environment at all. That is what decides whether a command runs in the client or on the daemon — see "Command boundary" in `src/partcad_cli/AGENTS.md`. **A remote daemon is never told to upgrade itself.** There is no upgrade or self-update method in the JSON-RPC surface and none may be added; that is a protocol rule, and it is the reason this package does not import `partcad_client`. Updating a *local* installation is `pc upgrade`, run by the client on its own machine. * [src/partcad_utils](./src/partcad_utils): The lightweight pieces **every** package shares without a CAD-kernel dependency: logging, telemetry, user configuration — and the client/daemon rendezvous, `framing` and `workspace` (which socket serves which workspace, and whether anything is answering on it). The rendezvous lives here precisely because neither end owns it: a copy on each side is a copy that can disagree, and a disagreement is a client silently starting a second daemon. `config_report` is here for the same reason one level down: it is how a configuration and a `PC_*` environment are *read back* — by `pc config`, by `pc system status config|env`, and by the daemon answering `pc daemon status config|env` for its own side. What it really holds is the redaction, and a redaction rule with a copy per caller is a rule with copies that stop redacting. `staging` is there for the same reason again, and is the other thing the two ends have to agree on: an assembly is built in two phases, and a daemon that finds sub-assemblies nobody has built yet does no work and answers "not yet — build these first", naming them. A client that reads that from a different copy of the rule reports the daemon's "ask me again" to the user as a failure. `timeouts` is the third: an assembly that declares `timeout:` is worked on inside a window the daemon announces (as a pair of `log` markers, like the process and action ones), and the client waits that long instead of its default five minutes for as long as it is open. A client reading the markers differently from the daemon writing them gives up on exactly the assemblies that said they would be slow. The same key is what `--fast-only` leaves an assembly out for (`partcad.fast_only`). `garage` is the one piece here that only *clients* touch: the user's Build vs Buy choices, one file per object under `~/.partcad/garage/default/bvb/`, written by the IDE and read by `pc instructions`, and sent to the daemon with the request that needs them rather than read by it — a daemon can be remote, and these are the decisions of whoever sits at the client. The editor extension has a TypeScript copy of its path and escaping (`ide/vscode/src/common/garage.ts`), which is why the escaping is the one both languages have built in. * [src/partcad_client](./src/partcad_client): What a **client** does, and a daemon must not: discovering the daemon serving a workspace and connecting to it (`daemon`, `client`), replacing this installation of PartCAD (`selfupdate`), and opening a file in a third-party CAD application on this machine (`external`). All of it acts on **this machine**, from the process running out of it. A daemon can be remote, where "update PartCAD" would mean updating somebody else's installation and "stop the local daemons" somebody else's daemons; and a daemon that went looking for its neighbours would be racing every client on the machine. A client is one process acting on its own machine, which is what makes `pc upgrade` stopping every local daemon a sane thing to do rather than a distributed algorithm. `selfupdate` itself knows nothing even about that: a caller passes `before_install`, which `pc upgrade` uses to stop the local daemons and wait for them. `pc upgrade` (the host-level command; `pc update` refetches a package's imports and is unrelated) ends up here, as does the VS Code extension's "Update PartCAD" — by running `pc upgrade`. Nothing about daemons or upgrading is reimplemented in TypeScript. It also refuses: `pc upgrade` run inside a bundle the editor extension downloaded errors out and says to update the extension instead, since the extension owns that bundle. `external` is the same rule applied to a window instead of an installation. `pc open` (and the VS Code extension's per-part "Open in..." menu, by running it) starts FreeCAD on the screen of whoever ran the command — on this machine, with this machine's file, and never over the wire; there is no RPC method for opening a file and none may be added. A machine with no local installation can run the application in a container PartCAD keeps for it, named after the tool (`partcad-freecad`), with the workspace and the daemon's socket mounted at the paths they have here and the host's X display forwarded into it. One application in that table reads meshes and nothing else — Blender — so an object that is not already one is converted to STL before it is handed over. That conversion is the single thing `pc open` asks the daemon for, because a CAD wrapper is what does it; the window still opens here, and the registry still has no `open` method. Which object types are meshes is `object_types`, an inlined copy of PartCAD's own tables (a client must stay cheap to import) that a completeness test keeps honest. * [src/partcad_ide_client](./src/partcad_ide_client/AGENTS.md): The Python side of the socket protocol `partcad` uses to display shapes in the IDE's `PartCAD Viewer`. Lazily imported by `partcad.viewer`, and by nothing else. ### Everything else * [ide/vscode](./ide/vscode/AGENTS.md): Visual Studio Code extension for navigating through objects in a `partcad` project and UI interface to some of `partcad` functionality. Hosts the `PartCAD Viewer`. It is a **JSON-RPC client and nothing else** — it talks to `partcad-json-rpc` and contains no Python of its own. Published as `PartCAD.partcad-official` — the name is not `partcad` because the shim below holds that one, and the marketplace does not let two publishers share an extension name. * [ide/vscode-shim](./ide/vscode-shim/AGENTS.md): The `OpenVMP.partcad` marketplace entry, as a transition shim: no code, one `extensionDependencies` on `PartCAD.partcad-official`. The extension above used to be published by the `OpenVMP` publisher, and a publisher is half of an extension's identity — the new entry is a *different* extension as far as the marketplace and the editor are concerned, and nothing carries an installation across. So the old entry is not abandoned; it is replaced by a package that pulls the new one in, and an existing installation updates into it. Same shape as `dev-tools/shim/` below, and temporary in the same way. Do not give it a `main` or a `contributes`: both extensions are installed at once afterwards, and anything it contributed would be contributed twice. Its `name` stays `partcad`, which is the other half of the identity it has to keep — and the reason the extension above had to take a different one. * [ide/standalone](./ide/standalone/AGENTS.md): The **PartCAD IDE**: a rebranded [VSCodium](https://vscodium.com/) build carrying the extension above, the extensions this repository recommends, and the standalone command line tools -- one application to download, for users who have no Python and no editor set up. It always opens in the PartCAD workbench. Installed with `install.sh --ide`. * [cad/freecad](./cad/freecad/AGENTS.md): The `PartCAD` addon (workbench) for FreeCAD: browse packages, parts and assemblies as a hierarchy, set an object's parameters in a generated dialog, and import the result into the open document as a STEP file. Like `ide/vscode` it is a thin client of the JSON-RPC service (the standalone PyInstaller bundle), because FreeCAD's embedded Python cannot host `partcad` itself. * [dev-tools/shim/](./dev-tools/shim/pyproject.toml): The `partcad-cli` compatibility package: no modules, no entry points, one dependency on `partcad`. It exists so that an older `pip install partcad-cli` keeps working. Do not give it modules or entry points — two distributions owning one import name or one console script break each other on uninstall, silently. * [README.md](./README.md) and [docs/source](./docs/source): Human-friendly documentation. `docs/source` is the Sphinx tree published to [Read the Docs](https://partcad.readthedocs.io/); `docs/source/index.rst` has its table of contents. ## Development process Full narrative guide (Docker/dev-container setup, PR merge criteria): `docs/source/contributing.rst`. Package-specific commands: `src/partcad/AGENTS.md`, `src/partcad_cli/AGENTS.md`, `src/partcad_service_json_rpc/AGENTS.md`, `src/partcad_ide_client/AGENTS.md`. Other components: `ide/vscode/AGENTS.md`, `ide/standalone/AGENTS.md`, `cad/freecad/AGENTS.md`. ### Where commands run **Validation and commits run inside the dev container, not on the host.** The container is the only environment where the pinned toolchain and the `pre-commit` hooks are available. `.devcontainer/devcontainer.json` is the single source of truth for it — the image, the dev container features, the mounts, the `SKIP` hook list, and the `pre-commit install` that runs as `postStartCommand`. Do not copy those values elsewhere; read them there. Human contributors normally enter this environment through the VS Code Dev Containers extension. An agent working in a terminal cannot, so use the `@devcontainers/cli` instead. It reads the same `.devcontainer/devcontainer.json` and produces the same environment. Start the environment (**on the host**, once per session; the first run is slow while features install): ```bash npx --yes @devcontainers/cli up --workspace-folder . ``` Run any command **inside** the environment: ```bash npx --yes @devcontainers/cli exec --workspace-folder . <command> ``` Everything below is written as the command to pass to `exec`. #### When there is no dev container to enter `devcontainer up` needs a Docker daemon, and some machines have none — a cloud agent session, a bare CI runner. There the container is not a thing to insist on; it is a thing that cannot happen, and the fallback is to install into the checkout and run everything directly: ```bash ./dev-tools/setup-native.sh # git-lfs, poetry install, OpenSCAD, and what installing outside the container gets wrong ``` Then drop the `devcontainer exec` prefix from every command below and keep the `poetry run` one. That script installs **OpenSCAD** as part of setting up, because PartCAD treats it as part of the toolchain rather than as an optional extra — the standalone bundles carry one, `pc healthcheck` asks after it, and a `.scad` part fails without it rather than degrading. It stops if it cannot get one, rather than leaving that to be found by a test run half an hour later. That script also installs **git-lfs** and runs `git lfs install --local`, and that one is not a convenience. `.gitattributes` routes every `.png`, `.jpg` and `.svg` through the `lfs` filter, and git resolves a `filter=` attribute naming a driver that no config defines by storing the file *verbatim* — no warning, no error, nothing in the commit to look at. So committing an image from a machine without git-lfs writes raw bytes at a path declared to hold a pointer, and the damage surfaces only on someone else's machine, as `Encountered N files that should have been pointers, but weren't` plus N files that `git checkout` cannot clean (git keeps cleaning their real bytes into a pointer and comparing that against a raw blob). The four images under `examples/feature_render/images/` were committed that way and have since been repaired. Repairing such a file is `git add --renormalize <path>` and a commit; the raw blob stays in history, which for a handful of small images is not worth a `git lfs migrate` rewrite of a shared branch. Every exception is written down in `.gitattributes`, with its reason beside it, and most of them are build **inputs** — the `.ico`, the `.bmp`s, `logo_128x128.png`, the extension's and the FreeCAD workbench's `.svg` icons, the `.svg` sketches two examples *read* as parts. That rule exists because no checkout in `.github/workflows` passes `lfs: true`, so anything a build reads back must not be an LFS pointer. Rendered output that nothing reads back belongs in LFS. Read the file before adding to that list: not every entry is a build input — `produce_sketch_basic` is held out on size instead — and an exemption granted for the wrong reason is the kind that is never revisited. This is still a fallback and not a second supported environment. What it does not give you: * **`pre-commit`.** It is installed by the container's image, not by `poetry install`, and `.git/hooks/` is written by `pre-commit install` running *inside* the container. So on such a machine there is no hook to fail and `git commit` silently runs no gate at all — which is worse than a hook that refuses, because nothing tells you. Run what the hooks run (`pytest`, `behave`, and the linters) before committing, and read their output; CI runs them either way. * **A Docker daemon.** The KiCad example needs one, and so does the `docker` Python sandbox — which is now the default wherever a daemon answers *and* PartCAD's base image can be had, so a machine with Docker running is a machine that renders in it. (The `remote` sandbox needs no daemon here at all: it needs a reachable `partcad-service-remote-docker`, which has one.) Nothing has to be declared for a machine that simply has no daemon: the KiCad test skips on one that is unreachable exactly as it does on one that was turned off. `PC_USE_DOCKER=false` (or `useDocker: false`) is the explicit opt-out — for a machine that *has* a daemon and should not use it, and for a container image built without one, which can then say so once rather than discovering it per subject. It skips on *that* and nothing else: an image it cannot pull, a `kicad-cli` that errors, a part that comes back empty are all still failures, because those are the subject being broken rather than the machine not having a container runtime. * **conda.** Without it the Python sandbox falls back to `venv`, which is a real sandbox and passes the suite; it just cannot provision an *interpreter version*, so a package asking for a Python this host does not have renders on the host's and says so. See `pythonSandbox` in `src/partcad_utils/user_config.py`. `pc healthcheck --filters sandbox` says which of those two this machine has. Missing conda and a missing container runtime are each a warning, since either one alone renders everything; `SandboxAvailable` is the one check that exits non-zero. It asks "conda or a container" only where nothing was declared, which is where PartCAD is the one choosing; a stated `pythonSandbox` is checked against what that sandbox needs, so `PC_PYTHON_SANDBOX=venv` passes on a machine with neither. Whatever asks "can we use Docker here" asks `runtime.docker_enabled()` — permission (`useDocker`) and a real ping, cached — so the healthcheck, the sandbox default and the KiCad importer cannot come to different conclusions about the same machine. **Never run the whole `behave` suite here — run the one feature a change touches.** Every scenario takes a throwaway `$HOME` (the `Given I have temporary $HOME` in each feature's `Background`), so a scenario that renders anything builds a CAD sandbox of its own from nothing and deletes it afterwards: ~2.7 GB and minutes of `pip` each, across 166 scenarios, and several of them on disk at once under `behavex`'s parallel workers. That is hours and tens of GB, and on a machine with a fixed disk allowance it ends in "no space left on device" rather than in a result. So: ```bash poetry run behave features/<name>.feature # yes poetry run behave # no, not here ``` A green whole-suite `behave` is **not** a prerequisite for opening a pull request from such a machine; CI shards that suite and runs it there. Say in the pull request which features you did run. One failure mode is worth recognising on sight, because nothing about it names its cause: **two wheels that install the same file can leave the checkout segfaulting.** Poetry installs in parallel, so both workers can write that one path at once and what lands is a blend of the two — reported as success by both. An `import` of a native module like that dies inside the dynamic loader, so pytest *collection* ends with `Fatal Python error: Segmentation fault` and no failing test to point at. `dev-tools/check_installed_files.py --fix` detects and repairs it, and `setup-native.sh` runs it. Do not go looking for a bug in the change under test. The pair that did this was `cadquery-ocp` and `cadquery-ocp-novtk`, both of which ship one 160 MB `OCP/OCP.cpython-*.so`. `cadquery-ocp` is no longer named in `pyproject.toml` — nothing here imports `cadquery` in process, and build123d pulls the novtk build in regardless — so one distribution owns the file and this cannot happen to *that* file any more. The checker stays because the next such pair will not announce itself either. It also catches the other half of it, which **an existing checkout hits exactly once**: an uninstall deletes the files its RECORD names, including the ones the wheel beside it also installed. So `poetry sync` removing `cadquery-ocp` takes `OCP/` away from `cadquery-ocp-novtk`, which stays installed, and `import OCP` stops working with nothing said about it anywhere. `check_installed_files.py --fix` reports and repairs that too. ### Environment setup Dependencies are already installed in the image. Only re-run this if you change `pyproject.toml`: ```bash poetry install # installs the `partcad` distribution -- all six packages -- in editable mode ``` **If `pc` fails with `ModuleNotFoundError: No module named 'partcad_cli.click.command'`, the `.venv` predates the one-wheel layout.** It still holds the editable install of the old root project, `partcad-dev`, whose `.pth` points at the six deleted `<package>/src` directories and whose `pc` script points at the pre-rename entry point. `poetry install` does not replace it, because the distribution was renamed and Poetry does not know it is there; `pip uninstall partcad-dev` refuses to remove it. The old source tree lingers too: `git` leaves `partcad/`, `partcad-cli/` and their four siblings behind when the only files left in them are ignored ones. Delete both and install again — the `.. warning::` beside `poetry install` in `docs/source/contributing.rst` has the commands. The project virtualenv is not auto-activated, and `pytest`, `pc`, and `partcad` are **not** on `PATH` — prefix project commands with `poetry run`. Pass the global `--no-ansi` flag whenever `pc` is run non-interactively — in scripts, in batch jobs, and especially when an LLM agent parses the output. Without it, `pc` draws animated ANSI progress bars whose control characters corrupt captured output; with it, output is plain text with `INFO:`/`ERROR:` prefixes, each line led by the UTC time the daemon logged it (`2026-10-02T14:31:01.816Z INFO:partcad:...`). Every process ends in a `DONE: <op>: <package>[: <item>]: <seconds>s` line, in either mode, and with `--verbose` so does every action; a recursive `pc test` runs each package as an action, so a verbose log of a long run says what each package took. Note that `--no-ansi` routes those logs to **stderr** (plain `logging`), whereas the default ANSI renderer writes to **stdout** — so capture both streams (`2>&1`) when parsing. The flag is global and goes before the subcommand: `poetry run pc --no-ansi info`. Note that `poetry.toml` sets `in-project = true`, so the virtualenv lives at `./.venv` inside the bind-mounted workspace and is shared between host and container. Running `poetry` on the host after running it in the container (or vice versa) makes each side rebuild `.venv`, because the interpreter paths baked into it are only valid on one side. Keep Python work on one side — the container — to avoid the thrash. `.venv` is gitignored, so this never affects a commit. ### Tests From the repo root, inside the environment: ```bash poetry run pytest tests cad/freecad \ -x -p no:error-for-skips -p no:warnings --dist no # unit tests (matches CI) poetry run behave # integration tests (./features) ``` **A warm daemon outlives a checkout, and one test says so.** `pc version` reports the version of the `partcad` the *daemon* loaded, which is the useful answer and not this process's — so after anything that moves the tree across a version bump (a rebase, a branch switch), a daemon still running the old code fails `tests/partcad_cli/unit/test_version.py::test_version` against the new `partcad_cli.__version__`, and passes again when run on its own once that daemon has been replaced. It is the skew being reported rather than a flake: `pc daemon stop` before re-running, and read it as a reminder that every other local run was served by that same daemon. CI fans these out over operating systems, and how much of that fan-out a run gets is decided in two places, which answer two different questions. `.github/actions/test-depth` answers **how deep**, in three tiers. A pull request gets `pr`: every image except macOS, and the oldest and newest supported Python only. Both Windows images stay on every tier and that is deliberate — Windows is where a path separator written on Linux goes wrong, so it is the platform a pull request most needs, not the one to economise on. The merge queue gets `queue`, which is what a pull request used to get — every current image, macOS included, and the full Python range — so the coverage a pull request drops is coverage the commit still earns before it lands, once per merge rather than once per push. Everything else gets `deep`: the nightly schedule, a manual dispatch, any push, and a pull request whose title or description contains `#deepTest`. `#deepTest` runs exactly what it ran before any of this existed. It answers a second, narrower question the same way: `#images` rebuilds PartCAD's own container images (see below). A third gate, `.github/actions/container-images`, answers **which images this run is about**. PartCAD's container images — the Python sandbox bases and the KiCad sandbox — are tagged with the release, and only the version bump on `devel` writes those tags, so until this existed a change to `tools/containers` built the new images and then tested against the last release's: a Dockerfile fix could not be proven on the pull request that made it, which is how the `pycairo` fix sat in the repository while every PNG went on failing. Such a run now builds and publishes `<release>-<branch>-<digest>-<commit>` and exports `PC_CONTAINER_IMAGE_TAG`, which `partcad_utils.container_image.image_tag` reads and every reader of those images goes through — so the tests in that run reach what that run built. It is turned on by a change under `tools/containers/` (a `changed-scopes` bucket) or by `#images` in the pull request, and it is off otherwise, which is why an ordinary pull request pays nothing extra for it -- it goes on building these images for `amd64` as a test, the way it always did, and goes on rendering against the release's. A branch tag is safe to publish from an unreviewed branch because nothing but that run asks for it -- the commit is in the name because a branch is not unique in time, and two pushes to one branch would otherwise build, test and *delete* one tag between them; the release tag, which somebody else pulls, is still only ever written by the bump. Both `CI` and `CI-Dev` call that action rather than deciding for themselves — they hand the answer to the same `Container (KiCad)` build, and two answers would be two tags for one image. Two consequences of that gate are worth knowing. **A test job does not build these images**: it pulls what `Build Docker Containers` built, which is why every job that can reach a container waits for that one. `.github/actions/sandbox-image` used to build a copy per job, for a reason the gate now answers; what is left is a pull, plus a build for the one caller with no such job to wait for (`Examples via bundle` in `Standalone`, which runs on the version bump in a *different* workflow from the one publishing that release's images). Both that build and `Build Docker Containers` run `dev-tools/ci/build-sandbox-image.sh` — there is one answer to "how is this image built", because two spellings of it do not fail when they drift, they disagree: a job testing an image built differently from the one the run published, under a name saying they are the same. A change to that script is a container change, like a change to a Dockerfile. And **the branch tags are cleaned up**: `CI` deletes the Python ones when its test jobs finish (it is their only consumer), and `prune-container-images.yml` sweeps nightly for the rest — the KiCad tag, which `CI` must not delete because `CI-Dev` reads it too, whatever a cancelled run left, and the `partcad-devcontainer` tags `setup-devcontainer` has published on every non-bump run since long before any of this. The sweep keeps anything that is not `<release>-<not py<N>>` and anything under a month old, so the release tags and the moving `py<N>-<arch>` tags are out of its reach by construction. `.github/actions/changed-scopes` answers **which jobs at all**, by sorting the changed files into buckets: a documentation-only change runs the documentation build and nothing else, an `ai-agents/` change runs the Claude Code plugin, a `.devcontainer/` change runs the container's behave and `pc` jobs but not its pytest. It is fail-safe — a path it does not recognise counts as both source and a dependency, so it runs everything a source change runs, the standalone bundles included. It does not turn on the four subjects that only their own directory turns on (the documentation, the extension, the IDE, the plugin), and that is not a gap: each is built from one fixed directory, so a path outside them cannot change what they contain — and it is a job condition rather than a `paths:` filter, because `merge_group` supports no `paths:` filter (so a trigger-level list is one the merge queue ignores, which is how a README typo used to freeze four standalone bundles in the queue) and because a workflow skipped by `paths:` never creates the check run a *required* check waits for, while a skipped job reports `skipped`, which counts as passing. Do not move these gates back onto the triggers. One bucket boundary in there is a deliberate trade rather than a fact, and it is the standalone bundles. `Standalone` is gated on **dependencies** (`pyproject.toml`, `poetry.lock`, root `requirements*`) and on `dev-tools/pyinstaller/`, `dev-tools/snap/`, `.snapcraft.yaml` and `install.sh` — not on `src/**`. Freezing is the most expensive thing here, and what makes a bundle differ from a working wheel is nearly always what went into it. A source change *can* break the freeze all the same (see `dev-tools/pyinstaller/README.md`), and the safety net for that is what `devel` does after the merge: the version bump that follows it is the same tree with a version on it, and `build-standalone.yml` freezes there. Short of `#deepTest` that bump is the only thing that builds a bundle for a source change, so do not narrow what a push to `devel` runs. `docs/source/contributing.rst` explains both to contributors. Note that a push to `devel` runs no matrix at all unless its head commit message starts with `Version updated` — the `set-matrix` job, and every job that depends on it, is skipped otherwise. That holds for every workflow now, `Standalone` and `IDE` included. Those two used to run on the merge as well: the same tree was built twice, minutes apart, and the first set of artifacts carried the version that merge had just replaced. Neither carries a `paths:` filter on its push trigger any more, because the gate is what decides and a filter in front of it could only ever hide it — a bump touches `pyproject.toml` and `ide/vscode/package.json` today, and the day `dev-tools/bumpversion.toml` stops naming such a file nothing would ever be built on `devel` again, with nothing to say so. **Neither gate trusts pytest's exit code.** On Windows it disagrees with the run in both directions — exit `0` with a test having failed (which is what #444 was written for), and exit `127` after a session where every test passed (which is what `Pytest (windows-*, 3.12)` does today). So the `pytest` `pre-commit` hook and the `Pytest` job both set `PYTEST_RESULT_MARKER` to a PID-unique path they then read, and the `pytest_sessionfinish` hook in the repository's root `conftest.py` writes `success` into it only when pytest's **final** exit status is clean **and** it counted no failed tests. It is the outermost wrapper and reads `session.exitstatus` rather than the status it is handed, because pytest's terminal reporter can still raise that status after the inner session hooks have run — `--max-warnings` being exceeded is how — and reading the argument would record a success for a session pytest then failed. That hook belongs at the root and nowhere else: a copy scoped to one package's tests records nothing for a run that does not collect that directory, and a gate reading no marker fails — which is also what keeps a crash mid-suite from passing. Do not move it, do not make it a plain (non-wrapper) hook, and do not let anything read the exit code instead. The dev container's own jobs in `test-dev.yml` lost a run's result a second way, and it is worth recognising because it looks like nothing: a `devcontainers/ci` `runCmd` is one script, so the step's result is the **last** command's. `Run: pytest` ended with `echo DONE` and reported a passing job over a failed test — for as long as nobody compared the job's colour with the JUnit it had just uploaded. `Run: behave` and `Run: pc` ended with `coverage xml`, which succeeds after a failing run because coverage writes its data whatever the program exited with. Every one of those scripts now starts with `set -e`, and the two that have a report to write keep the status and `exit` it at the end. Do not end such a script with a command whose success is not the result. That dev container holds the *host's* `/var/run/docker.sock`, so the daemon it talks to resolves bind mounts against the host's filesystem rather than the container's -- and the `docker` Python sandbox works there anyway. `mount_sources` in `runtime_python_docker.py` writes a file and has a throwaway container look for it; when it is not found, PartCAD finds its own container on that daemon, binds each directory from where the daemon keeps the mount holding it, and keeps the target, so inside the sandbox every path is still the one PartCAD sees. That is why `devcontainer.json` puts `~/.partcad` and `/tmp` on per-container volumes (the workspace is a bind already): a directory that lives only in the container's own layer is one the host's daemon cannot reach, and the sandbox refuses, naming it, rather than binding an empty directory of the same name. `PC_DOCKER_MOUNT_SOURCES` (`here=there;...`) says the same by hand. There the sandbox binds *every* directory mount of its dev container, so that every process in it -- a test with a temporary `~` as much as the daemon with the real one -- asks for one mount set. A sandbox container is named after its image **and** its mount set (`container_name`), on an ordinary host too: named after the image alone, any process needing other mounts (another dev container on the same machine, a package outside the home directory) replaced the shared container, and whoever had started it went on running commands by name in a container without its files. Where the host's socket is not the container user's to write (GitHub's runners), `.devcontainer/docker-socket-start.sh` proxies it with `socat`, and that proxy needs `-t`: with socat's default half-second half-close timeout, `docker exec -i` returned exit 0 the moment its stdin closed while the command went on running, which is what failed every dev-container CI job on the `docker` sandbox (#727). A `DOCKER_HOST` on another machine has no such answer and still falls back to conda; when a package's own `dockerImage` is skipped for any such reason, PartCAD warns once and repeats the reason in the failure. It is one probe per process; see the docstring for what a wrong answer costs in each direction. The packages under `examples/` are a third suite. The images and `README.md` files there are what `cd examples && pc render -r` produces, and they are checked in so that a change in how PartCAD renders is a diff someone has to look at rather than something a reader of the README discovers. If a change affects a projection or a generated document, re-render and commit the result. The `example-images` `pre-commit` hook catches the cheap half of this instantly (a README pointing at an image that is not checked in); the `Examples (PartCAD)` job in `test.yml` renders everything and fails if the tree changed, on one cell of the matrix because what is checked in is one rendering. "Everything" short of what declares a `timeout:`: every recursive run over `//pub` in CI says `--fast-only`, which passes over an assembly or a scene whose package says it is slow (`partcad.fast_only`), so an example like that is re-rendered locally and nowhere else. **Every drawing under `examples/` is rendered with `reproducible: true`, and a new one has to say so too.** That is the flag every `render:` and `export:` file type takes, `false` by default, and it is what makes a checked-in drawing a baseline rather than a diff every time: the SVG projection goes through OpenCASCADE's exact hidden-line algorithm instead of the polygonal one (the polygonal one projects a triangulation, which differs between architectures), every number is rounded to the `precision` the file type claims, and a DXF gets fixed header metadata instead of the clock and a fresh pair of GUIDs. It is off by default because the exact projection is the slower of the two and is the one that can walk off the end of an OCCT allocation — a picture produced only to be looked at should be the fastest correct one. See `examples/partcad.yaml`, which says all of this once for the tree, and `output.REPRODUCIBLE_KEY`. It is a floor and not a promise: it settles everything PartCAD chooses, and what is left is the CAD kernel's arithmetic, on which two architectures can still disagree along a curved silhouette. An implementation another package supplies may not be reproducible at all, and those files are named one by one in that job's `UNSTABLE` list — keep it short, and give every entry a reason there and in the package it belongs to. **Coverage is merged in the repository, not by a service.** Codecov is gone: every suite uploads its raw `.coverage` data as a `coverage-data-*` artifact, and the `Coverage` job in `test.yml` runs `dev-tools/ci/coverage_report.py` over all of them — `coverage combine`, then the HTML report as the `coverage-html-report` artifact, one pull-request comment edited in place (`dev-tools/ci/pr_comment.py`, found by an invisible marker), and the gate. The merge is a **union of line numbers**, not an average of percentages, which is the only reason the number means anything: these suites overlap heavily and each covers what the others cannot. What makes that union possible is the `[paths]` section of `dev-tools/coverage.rc`, mapping the three roots one file is recorded under — the checkout, `site-packages`, and either with Windows separators — onto one; without it the report is produced, uploaded and commented on with every rate silently too low. Every producer must also **measure with that same file**. `coverage.rc` sets `branch = True`, and `coverage combine` refuses to mix branch data with statement-only data — it exits 1 with "Can't combine statement coverage data with branch data", which is not a degraded report but no report at all. The suites driving `coverage run` pass the file explicitly; the `Pytest` job measures through pytest-cov, which finds no configuration here on its own (there is no `.coveragerc` and no `[tool.coverage]` table) and would default to `branch = False` — so `addopts` in `pyproject.toml` names `--cov-config=dev-tools/coverage.rc`. That is the whole reason it is there, and the first run of the `Coverage` job with real data from every suite died on its absence. A job joins the merged report by passing `coverage-data:` to `.github/actions/upload-test-results` and nothing else; that prefix is the whole contract. Two details there are load-bearing rather than incidental: the value is a **glob** (`.coverage*`) and the step runs on `always()`, because a suite that died mid-run never reached its own `coverage combine` and what is on disk then is the parallel-mode parts — so a bare filename on a `success()` step silently drops a whole job from the merge, and with it moves the requirement's floor. That holds for the jobs driving `coverage run` themselves; the `Pytest` job measures through pytest-cov, which writes **nothing** when its session fails, so a failed `Pytest` contributes no coverage and no arrangement of the upload step changes that. And `always()` covers a job that *failed*, not one the runner *killed*: a job that hits its own `timeout-minutes` never reaches the step at all, so everything it had measured is absent from the merge rather than partially in it — however many data files that would have been, which is not always one (an example sweep writes a `.coverage.<n>` per `coverage run` before combining them). The merge takes that in its stride and produces the report over what did arrive. The merge also **records every in-scope file no job imported, at nought percent**, before writing any report: coverage.py reports the files it saw, so without that a brand-new module with no test at all is absent from the data rather than zero in it, and the gate finds nothing to hold it to. The requirement is a floor under **patch** coverage — the statements the pull request touched — set to the project's own statement rate in the same run. Nothing is stored between runs, so there is no baseline to maintain and the bar cannot drift; a change touching no measured statement passes with a notice. Two things it deliberately does not do: it does not fail a fork's pull request over the comment it cannot post (GitHub gives such a run a read-only token; the job summary carries the same report and the gate still gates), and it does not include `CI-Dev`'s coverage, because a `needs:` does not reach across workflows — the same fact the KiCad image cleanup is built around. Lint/format (Python): **`black`, `flake8` and `isort` all gate.** Each is a `pre-commit` hook and a `Lint (...)` job in `test.yml`, each pins the version `pyproject.toml` resolves so the hook and the job cannot disagree, and the tree satisfies all three. Run them as CI runs them, from the repository root: ```bash poetry run isort --check --diff --filter-files --settings-path pyproject.toml . poetry run black --check --diff . poetry run flake8 . ``` `.`, not a list of directories: **what is in scope is written down once, in `pyproject.toml`**, so that a hook, a CI job and a command you type cannot come to disagree about it. The scope is PartCAD's own Python — `src/`, `tests/`, `cad/`, `dev-tools/`, `ide/`, `tools/`, `docs/` and the root `conftest.py`. Two trees are held out, and it is a deferred decision rather than an oversight: * `examples/` is CAD part scripts written the way a *user* writes one — `from build123d import *`, and a `show_object` the runner injects at execution time. Essentially all 102 of flake8's findings there are that idiom (`F403`/`F405`/`F401`/`F821`), which is why `[tool.ruff.lint]` already waives those same three for `pc lint` of a part. Sorting their imports carries the wrappers' risk as well, and their rendered output is checked in. * `features/` is `behave` step definitions, where every module defines several functions named `step_impl` and the decorator is what tells them apart. 29 of its 56 findings are `F811`, "redefinition of unused 'step_impl'" — the framework's idiom, reported as a defect. Covering either means waiving a code across a whole directory, which is a policy call and a change of its own. Until then, do not "fix" a finding in those two trees as part of unrelated work. A fourth job, `Lint (pre-commit)`, runs the hooks that have no job of their own — `shellcheck`, `hadolint`, the YAML and workflow schema checks, the whitespace fixers, and the "a README's images are checked in" check. It skips the three above (two red checks for one problem is noise) and `pytest`/`behave` (their own jobs). Four things about the configuration are load-bearing, and each of them was once silently not: * **`--filter-files` for isort.** isort applies `extend_skip` / `extend_skip_glob` to files it *discovers* by walking a directory, and not to files handed to it by name — so without the flag, sorting a named `src/partcad/wrappers/*.py` reorders imports whose order is what the dynamic loader needs (the expat/VTK pin; the config comment has the detail). Pass a directory or pass the flag. * **`Flake8-pyproject` for flake8.** flake8 has never read `pyproject.toml` on its own. Without that plugin the whole `[tool.flake8]` table is inert and flake8 checks at its built-in 79 columns, on a repository that formats at 120. It is in the `dev` group for exactly this reason, and the CI job installs it explicitly. * **The `per-file-ignores` list is not a wish list.** Every entry is an idiom rather than a defect, and the first two are the same paths isort skips, for the same reason. `E501` is waived globally because *black* owns line length; `E722` and `E731` are deferred, not endorsed — see the comments in `pyproject.toml`. * **Each of the three spells "skip this" differently, and two of the three spellings are traps.** isort needs `--filter-files` (above); black needs `force-exclude`, because `extend-exclude` applies only to files it discovered by walking; and flake8 has no equivalent at all — `extend-exclude` is discovery-only and it checks anything named on its command line regardless. That last one is why the `flake8` hook in `dev-tools/pre-commit-config.yaml` carries an `exclude:` of its own: `pre-commit` passes staged files by name. `pyright` is configured under `[tool.pyright]` and read by Pylance, which `.devcontainer/devcontainer.json` recommends. It gates nothing: there is no type checker in any dependency group, and adopting one is a decision of its own. None of the four jobs is gated on `.github/actions/changed-scopes`, deliberately. They read the whole repository, and several of the trees that covers sit in buckets that leave `pytest` false — `ide/standalone/tests/*.py` is `ide`, `docs/*.py` and every `AGENTS.md` are `docs`, `dev-tools/pyinstaller` is `packaging`. Gated, a change to any of those would skip the linters altogether. Each job is seconds on one runner. They do keep `needs: set-matrix`, which is what makes the "a push to `devel` runs nothing unless the commit is a version bump" rule apply to them as well. **A `Lint (...)` job is only a gate once branch protection requires it.** A job added to `test.yml` does not apply to a pull request whose branch was cut before the job existed — the check simply never runs there — and GitHub will merge such a branch unless the check is *required* and branches must be up to date. That is not hypothetical: #629 merged three unsorted files 83 seconds after the isort gate landed in #633, because its own CI run predated the job. Adding a lint job therefore has a second half that lives in the repository settings and not in this tree. ### Packaging Six artifacts ship from this repo: **one Python wheel** (`partcad`, carrying all six packages and all four entry points, with a `partcad-cli` shim published beside it from `dev-tools/shim/` so the older install instruction keeps working), the standalone PyInstaller bundles for users who have no Python, the PartCAD IDE, which carries those bundles inside it, the VS Code extension's `.vsix` (with the `ide/vscode-shim` `.vsix` published beside it, for the same reason the wheel has one), the `pc` plugin for Claude Code, and the snap, which wraps the Linux bundle and is built but not published yet. There used to be five wheels pinning each other at `==`. Do not add a second distribution back: within one distribution a pin is an import, and two distributions owning one import name break each other on uninstall without pip noticing. Adding a runtime dependency, an optional extra, or a file that is read at runtime can be invisible to the frozen bundle and break it while the wheel stays fine — see `dev-tools/pyinstaller/README.md` before doing any of those. Note that the bundles fan out over *OS versions* (`ubuntu-22.04-x86_64`, `macos-15-arm64`, …), and that the same platform ids appear in several places that mostly nothing keeps in sync; the README says which, and which of them a pull request skips without `#deepTest`. The `.vsix` is built once by `.github/workflows/vsix.yml`, which `build.yml` and `deploy.yml` both call, and `ide/standalone/build.sh` runs the same `npm run vsce-package` for the copy inside the IDE. One build serves every platform: the extension is a JSON-RPC client with no Python and no compiled content in it. The same workflow packages the transition shim beside it, under the extension's version — which the shim does not state anywhere, but reads at package time, so the two cannot drift. A shim older than the entry it replaces is one the marketplace never delivers, and a second literal to bump is how that happens. Changing `.vscode/extensions.json` changes what the IDE ships with — see `ide/standalone/README.md`. The plugin is built the same way, by `.github/workflows/plugin.yml`, and published two ways by `deploy.yml`: `pc-<version>.zip` on the release, and the `plugin-dist` branch, which is what `/plugin marketplace add partcad/partcad@plugin-dist` reads. It has no version of its own — `plugin.json` is in `dev-tools/bumpversion.toml` like everything else — and it must not get one back: it had one, and stayed at 0.1.0 for twenty-three releases because publishing it meant remembering a tag nobody pushed. **The skills it is made of also ship in the wheel**, through two symlinks under `src/partcad/ai_agents` into `ai-agents/`, because `pc init` installs them into the repository it creates a package in and the wheel is what a user has: the plugin for Claude Code, `pc-`-prefixed copies for Cursor. The skills stay at the top of the repository where a visitor finds them, and there is one copy of each file. So `ai-agents/common` is *both* the plugin and the distribution, which is why `.github/actions/changed-scopes` classifies it into both buckets, why `pyproject.toml` and `dev-tools/pyinstaller/partcad.spec` both name it as data, and why a new file under a skill has to be covered by the `package-data` patterns or it is simply absent from what gets installed. **The build refuses a checkout that dropped those symlinks** — `pyproject.toml` names an in-tree PEP 517 backend, `dev-tools/build-backend/`, which is `setuptools.build_meta` plus that one precondition on `build_wheel` and `build_sdist`. Without it the artifact is silently empty: `skills/**/*` matches nothing where `skills` is a text file holding a path, `plugin.json` matches that same kind of file and is packaged as its content, and the wheel then installs, imports and runs `pc version` with no skills in it. `build_editable` is *not* guarded, on purpose: an editable install reads the working tree live, and refusing there would fail `poetry install` over a data file. The installed copies have a lifecycle of their own. Re-running `pc init` updates them and **removes what PartCAD no longer ships** — a retired skill left behind describes a CLI that has moved, and the agent follows it anyway. The Claude plugin directory is entirely PartCAD's, so anything unshipped there goes; under `.cursor/skills` the `pc-` prefix is not proof of authorship, so those copies carry a `metadata.partcad` stamp and only stamped ones are removed. That stamp is `partcad.__version__` read at install time and **must not become a literal**: a literal is one more `dev-tools/bumpversion.toml` entry and one more thing to forget, which is how the plugin manifest sat at 0.1.0 for twenty-three releases. The `AgentSkills` healthcheck reads the same stamp — `pc upgrade` replaces PartCAD and leaves the skills alone, so something has to notice. See `ai-agents/README.md`. The snap carries whatever the bundle carries, so it needs nothing extra of its own; `dev-tools/snap/README.md` covers what is specific to it (confinement, aliases, the base, its state directory). Its build tooling lives beside that README, but the recipe, `.snapcraft.yaml`, stays at the repository root and cannot move down into `dev-tools/` with it: the directory `snapcraft` runs in is the project directory — what gets copied into the build environment and what `source:` resolves against — and snapcraft looks for the recipe only at four paths within it, the root itself, `snap/`, or `build-aux/snap/`. Running it from `dev-tools/` instead would leave the `dist/standalone/partcad` bundle it packages outside that directory. The dotfile is the root-level spelling that leaves no directory behind; the comment at the top of the file says all of this too. ### Committing This repo uses `pre-commit` (config at `dev-tools/pre-commit-config.yaml`) to run formatting/lint checks, `pytest`, and `behave` on commit. These hooks are required to pass in CI before a PR can merge — do not skip them with `--no-verify` unless explicitly instructed to. Run the commit inside the environment: ```bash npx --yes @devcontainers/cli exec --workspace-folder . git commit -m "<message>" ``` Check the gates before committing, so hook failures are separated from commit problems: ```bash pre-commit run --config dev-tools/pre-commit-config.yaml ``` Hooks that reformat files (`trailing-whitespace`, `end-of-file-fixer`) rewrite them in place — re-stage anything they touch, then commit. **A `-F <file>` message has to live inside the workspace.** The commit runs in the container, and the only host directory that reaches it is the bind-mounted checkout — so a message written to the host's `/tmp` fails with `fatal: could not read log file '/tmp/...': No such file or directory`, *after* the whole hook suite has run. Write it under the repository instead (`.git/` is in the bind mount and is never committed) and pass it as a relative path. **If `git commit` fails with `` `pre-commit` not found ``, you are committing on the host, not in the container.** `.git/hooks/pre-commit` is generated by `pre-commit install` running *inside* the container, so it hardcodes an interpreter path that exists only there. The fix is to re-run the commit inside the environment. It is never to retry with `--no-verify`, and never to install `pre-commit` on the host — host tool versions are not the pinned ones, which is how a commit passes locally and then fails CI. **If the commit fails with `Author identity unknown`**, the container has no git identity. The VS Code extension copies your host gitconfig in; the CLI does not, and anything written to the container's home directory is lost when the container is recreated. Set the identity repo-locally instead — `.git/config` lives in the bind-mounted workspace, so it survives recreates and is never committed: ```bash git config --local user.name "<your name>" git config --local user.email "<your email>" git config --local user.signingkey "<your key id>" # only if you sign git config --local commit.gpgsign true # only if you sign ``` Do not mount your host `~/.gitconfig` into the container to solve this. If it contains `url.*.insteadOf` rules rewriting `https://github.com/` to SSH (a common setup), the `git-lfs` feature's post-create step will try SSH, find no key in the container, and fail the whole `up`. **If the commit fails to sign** (`gpg failed to sign the data`), the container has your public key but not your private key. The VS Code extension forwards your GPG agent automatically; the CLI does not. Forward the agent's extra socket when starting the environment, which keeps the private key on the host: ```bash npx --yes @devcontainers/cli up --workspace-folder . \ --mount "type=bind,source=$(gpgconf --list-dirs agent-extra-socket),target=/run/host-gpg-agent.sock" ``` Then point the container's agent socket at it (the socket lives in `/run/user/$(id -u)/gnupg/`, not `~/.gnupg/`): ```bash gpgconf --kill gpg-agent ln -sf /run/host-gpg-agent.sock /run/user/$(id -u)/gnupg/S.gpg-agent ``` Verify with `gpg --list-secret-keys` — your key should appear, served by the forwarded host agent. **If `pre-commit` fails to install a hook with `Permission denied (publickey)`**, a rewrite rule in your gitconfig is turning its fetch of the hook repository into an SSH fetch. `url."ssh://git@github.com/".insteadOf = https://github.com/` is a common setup, and the VS Code extension copies your gitconfig into the container, rewrite rules included — so pre-commit clones `https://github.com/...` and git dials `git@github.com`. A VS Code terminal has the forwarded SSH agent and succeeds; a `devcontainer exec` shell has no agent and does not. Two ways out, on the host: * Scope the rule to `pushInsteadOf` rather than `insteadOf`, which is usually what the rule is for anyway: push over SSH, fetch anonymously over https. * Or use the host's SSH agent, which is already bound in at `/run/host-ssh-agent.sock` — `.devcontainer/host-sockets-init.sh` resolves `SSH_AUTH_SOCK` on the host and `devcontainer.json` binds it, the same way the Docker socket arrives. Nothing points at it by default, because in the editor the extension forwards an agent of its own and sets `SSH_AUTH_SOCK` to that; in a `devcontainer exec` shell, which forwards none, `export SSH_AUTH_SOCK=/run/host-ssh-agent.sock` is the whole of it. No `--mount` of your own, so no recreating the container — which is what would leave it with no gitconfig at all, hence the repo-local identity above. An empty directory at that path means no agent was bound in, and there are two ways to get one: on a POSIX host, none was running when the container started — start one and restart the container. On a **native Windows host there will never be one**, because the Windows agent is the named pipe `\\.\pipe\openssh-ssh-agent` and a Linux container cannot be handed a named pipe as a unix socket; take the `pushInsteadOf` option above instead, or work from a VS Code terminal, where the extension forwards the Windows agent itself. (WSL2 is a POSIX host for this purpose and behaves like the first case.) `GIT_CONFIG_GLOBAL=/dev/null` does not work around it: pre-commit strips `GIT_*` from the environment of the git it runs, keeping only `GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_*`/`GIT_CONFIG_VALUE_*`, and those can only add configuration, not remove a rewrite. Note that this only bites on a hook repository that is not in `~/.cache/pre-commit` yet — a new `rev:`, or a fresh cache volume. The four Poetry hooks are declared `repo: local` in `dev-tools/pre-commit-config.yaml` precisely so that they need no repository at all. ### Verifying a commit landed Do not infer success from the absence of an error. Confirm it: ```bash git log -1 --stat # the new commit and its file list git status --short # working tree state afterward ``` Check that the hook output actually shows hooks running (`Passed`/`Skipped` lines) rather than the whole run being bypassed, and that the committed file set matches what you intended to stage.