Contribution guide

Good candidates for new package manager:

Document a new package manager

Not a coder? No problem.

You can still provide invaluable information. Open a new issue and fill in the form with raw output of CLI calls to your manager. Armed with this critical data, a contributor or maintainer can attempt a blind implementation. From there we’ll collectively iterate until we reach a usable level.

This is often the best approach, as it is sometimes hard to create the same environment as the users.

Code support for a new package manager

If you’re a Python developer, see the Add a new package manager guide for the full implementation checklist: module structure, registration, testing, and documentation updates.

claude.md file

This file provides guidance to Claude Code when working with code in this repository.

Project overview

Meta Package Manager (mpm) is a CLI that wraps multiple package managers (Homebrew, apt, pip, npm, etc.) behind a unified interface. It can list, search, install, upgrade, and remove packages across all supported managers simultaneously.

Upstream conventions

This repository uses reusable workflows from kdeldycke/repomatic and follows the conventions established there. For code style, documentation, testing, and design principles, refer to the upstream claude.md as the canonical reference.

Contributing upstream: If you spot inefficiencies, improvements, or missing features in the reusable workflows, propose changes via a pull request or issue at kdeldycke/repomatic.

Source of truth hierarchy

CLAUDE.md defines the rules. The codebase and GitHub (issues, PRs, CI logs) are what you measure against those rules. When they disagree, fix the code to match the rules. If the rules are wrong, fix CLAUDE.md.

Keeping CLAUDE.md lean

CLAUDE.md must contain only conventions, policies, rationale, and non-obvious rules that Claude cannot discover by reading the codebase. Actively remove:

  • Structural inventories — project trees, module tables, workflow lists. Claude can discover these via Glob/Read.

  • Code examples that duplicate source files — YAML snippets copied from workflows, Python patterns visible in every module. Reference the source file instead.

  • General programming knowledge — standard Python idioms, well-known library usage, tool descriptions derivable from imports.

  • Implementation details readable from code — what a function does, what a workflow’s concurrency block looks like. Only the rationale for non-obvious choices belongs here.

Philosophy

  1. First create something that works (to provide business value).

  2. Then something that’s beautiful (to lower maintenance costs).

  3. Finally works on performance (to avoid wasting time on premature optimizations).

Stability policy

This project more or less follows Semantic Versioning.

Which boils down to the following these rules of thumb regarding stability:

  • Patch releases: 0.x.n0.x.(n+1) upgrades

    Are bug-fix only. These releases must not break anything and keep backward-compatibility with 0.x.* and 0.(x-1).* series.

  • Minor releases: 0.n.*0.(n+1).0 upgrades

    Includes any non-bugfix changes. These releases must be backward-compatible with any 0.n.* version but are allowed to drop compatibility with the 0.(n-1).* series and below.

  • Major releases: n.*.*(n+1).0.0 upgrades

    Make no promises about backwards-compatibility. Any API change requires a new major release.

  • Unmaintained managers: managers whose unmaintained flag is set

    Are exempt from the rules above. A manager is flagged unmaintained when its upstream project is officially retired or we infer it is abandoned: archived on its forge, left without a release or commit for years (~3+), formally superseded by a successor, or part of a discontinued platform. A superseded-but-still-maintained tool (like a compatibility alias its upstream keeps shipping, such as yum fronting dnf) is not unmaintained.

    The commitment is to keep the wrapper for as long as that stays cheap: an unmaintained manager may still be removed, in part or in full, in any release and without notice, once keeping it working becomes too burdensome. Each flag is documented via the manager’s unmaintained_message (a markdown block rendered into the docs), and unmaintained managers are hidden from default selection and kept out of the functional and integration test matrices to save CI resources. An upstream that is merely slowing down does not earn the flag: it carries an informational maintenance note instead. Both render on the manager’s own page. See the unmaintained attribute in meta_package_manager/manager.py for the full policy.

    Being flagged is a different axis from being unsupported: an unmaintained manager is still wrapped and usable, whereas the tools in docs/unsupported.md were never wrapped at all.

Cooldown on every install

Every command that resolves a package from a live registry carries a cooldown, except where this section names otherwise. A cooldown refuses any version published more recently than a fixed window, so a compromised release has to survive that window before it can enter a build. Most malicious releases (stolen publishing credentials, dependency confusion, account takeover) are caught and pulled within days of publication, which is what makes a window of days worth the delay it costs.

mpm --cooldown applies the same idea to a different subject, and the two are easy to conflate here. That flag is a user-facing feature, gating the packages mpm installs on the user’s machine, and docs/cooldown.md is its documentation. This section covers what CI resolves onto a runner while building mpm itself. A comment or changelog entry naming one should not read as the other.

The rule has no scratch exemption. It binds reusable workflows, one-off CI steps, test scripts, local reproduction commands and throwaway experiments equally: an uncooled uvx in a five-minute debugging step resolves the same tree from the same registry onto the same runner as a production job.

A cooldown is not a pin, and neither is a checksum

The three guarantees are independent, and most of what CI installs has only one or two of them. Know which one you are relying on before calling something verified.

Guarantee

What it proves

Where this repo has it

Cooldown

The version has been public long enough for a compromise to surface

Every uvx, uv pip, uv tool, npm and npx invocation

Pin

Everyone resolves the same version

Action SHAs, the inline repomatic==X.Y.Z literal, uv.lock

Checksum

The bytes are the bytes that version shipped

uv.lock hashes

The gap worth naming: a uvx-resolved tree is gated by publication age but never checked against a known digest, because a uvx environment has no lockfile. uv.lock is the only place a Python dependency is hash-pinned, so anything resolved outside it trades hash verification for the cooldown alone.

Where the window comes from

[tool.repomatic] minimum-release-age is the single source of truth, left at its 1 week default here. Never hard-code a duration next to an install command: read it from config, or from the npm_min_release_age_days output repomatic metadata derives from it.

Two places carry the duration as a literal instead, and both must be kept equal to that source by hand:

  • Every workflow that installs anything, because YAML cannot read Python: each sets NPM_CONFIG_MIN_RELEASE_AGE and UV_EXCLUDE_NEWER in a workflow-level env: block. Job-level would not cover the bootstrap, since metadata resolves packages before any other job’s output exists and a workflow-level env: cannot reference needs. The literal covers every job, including that bootstrap and any step added later by someone who never read this section.

  • [tool.uv] exclude-newer, because uv reads its own config and knows nothing of [tool.repomatic]. The two must not merely be close: a lock window wider than the install window resolves versions those installs then refuse, leaving a package pinned in uv.lock that CI cannot install.

That makes the cooldown the one place an environment variable beats an explicit flag, inverting § uv flags in CI workflows: a flag only protects the command someone remembered to write it on, and the commands that most need protecting are the ones nobody thought about.

Per-ecosystem knobs

Ecosystem

Cooldown

Per-package exemption

uv: uvx, uv pip install, uv run --with, uv tool install, uv lock

--exclude-newer, or UV_EXCLUDE_NEWER

--exclude-newer-package pkg=YYYY-MM-DD, CLI flag only

npm, npx

--min-release-age in whole days, or NPM_CONFIG_MIN_RELEASE_AGE

--min-release-age-exclude taking a name or glob

uv accepts a friendly duration (1 week), an ISO 8601 span (P7D), or an absolute date; npm counts whole days and needs 11.10.0 or newer. Both knobs gate the whole resolved tree, transitive dependencies included, which is the point: the compromised package is rarely the one named on the command line.

For every other package manager, docs/cooldown.md is the inventory, and it is this project’s own: which managers enforce a cooldown natively, which have support proposed upstream, which have none, and which are N/A because their archive already stages releases on its own.

Distro archives are out of scope, not an exception

apk, pacman, xbps and their peers are not live registries, and this rule was never about them. A stable archive is frozen at release and moves only through the distro’s own staging, which is a cooldown implemented one layer down. Nobody self-publishes into it, which is the property the window exists to compensate for everywhere else. A distro version string is also the maintainer’s package build, not an upstream publish date, so a publish-date filter would have nothing to filter on. That is why tests-yay-cooldown.yaml, check-void-deps.yaml and the *-source jobs of tests-install.yaml carry no window: they drive pacman, xbps-src, abuild and nix-build inside their own distro containers.

The exception is a repository added by hand. A PPA or a vendor’s .repo file is a live, single-publisher registry wearing apt’s clothes, with none of the distro staging behind it. Pin the version there, or fetch a checksummed artifact instead.

Documented exemptions

Three installs deliberately bypass the window. Two are per-package and never widen to the rest of the tree; the third is a whole workflow, and says why it has to be.

  • The upstream toolkit’s own pin. repomatic runs from a pin that moves in lockstep with the uses: refs pointing at it, so a release must be installable the minute it is published. Every uvx call carrying it passes --exclude-newer-package repomatic=P0D beside the pin.

  • A dependency held at a known-good version. [tool.uv] exclude-newer-package holds extra-platforms at a fixed date, which keeps uv lock --upgrade on that version instead of tracking newer releases.

  • The tests-install.yaml workflow. Its subject is the freshly published artifact, so a cooldown would make the question it exists to answer unanswerable. It declares UV_EXCLUDE_NEWER: P0D at workflow level rather than relying on uv’s default, so the opt-out reads as a decision.

A fourth exemption is a bug until proven otherwise. Anything claiming one carries a comment naming what breaks without it, and the narrowest scope that still works: a package, not a job; a job, not a workflow.

Build status

main branch: Unittests status

Commands

Setup environment

Check out latest development branch:

$ git clone git@github.com:kdeldycke/meta-package-manager.git
$ cd ./meta-package-manager
$ git checkout main

Install package in editable mode with all development dependencies:

$ python -m pip install uv
$ uv venv
$ source .venv/bin/activate
$ uv sync --all-extras --all-groups

Test mpm development version

After the steps above, you are free to play with the bleeding edge version of mpm:

$ uv run -- mpm --version
(...)
mpm, version 4.13.0

Unit-tests

Run unit-tests with:

$ uv sync --group test
$ uv run -- pytest

Which should be the same as running non-destructive unit-tests in parallel with:

$ uv run pytest --numprocesses=auto --skip-destructive

Destructive tests mess with the package managers on your system. Run them sequentially:

$ uv run pytest --numprocesses=0 --skip-non-destructive --run-destructive

Sequential order is recommended as most package managers don’t support concurrency.

Note for downstream packagers

The canonical guidance for distribution packagers (test-suite layers, /homeless-shelter auto-skip, ignore-globs for writable-$HOME builders, dependency constraints, per-channel build instructions) lives in docs/packaging.md, published at https://kdeldycke.github.io/meta-package-manager/packaging.html. Packaging specs (packaging/nix/, packaging/alpine/, and their upstream submissions) must reference that URL, never this file.

Keep the comments in those specs tight. Their audience is each channel’s own maintainers, who already know their build-sandbox conventions (the /homeless-shelter auto-skip, standard make_check / test-phase behavior): drop those, drop doc-link pointers, and collapse verbose per-dependency breakdowns to a single line. Keep only non-obvious, spec-specific rationale: a live workaround still needed, or why a particular test or dependency is excluded. When unsure, favor the tighter comment. This holds for both the in-repo packaging/* specs and their downstream branch copies.

Type checking

$ uv run --group typing mypy meta_package_manager

Documentation

Build Sphinx documentation locally:

$ uv sync --group docs
$ uv run -- sphinx-build -b html ./docs ./docs/html

The docs group declares requires-python = ">= 3.14" of its own, above the project’s 3.10 floor, so a venv built on an older interpreter resolves the group to nothing and sphinx-build is simply absent rather than broken. Narrowing it is what lets the documentation dependencies carry flat version floors: see the comment on dependency-groups.docs in [tool.uv].

The generation of API documentation is covered by a dedicated workflow.

Documentation requirements

Scope of CLAUDE.md vs readme.md

  • CLAUDE.md: Contributor and Claude-focused directives — code style, testing guidelines, design principles, and internal development guidance.

  • readme.md: User-facing documentation — installation, usage, and public API.

When adding new content, consider whether it benefits end users (readme.md) or contributors/Claude working on the codebase (CLAUDE.md).

Knowledge placement

Each piece of knowledge has one canonical home, chosen by audience. Other locations get a brief pointer (“See module.py for rationale.”).

Audience

Home

Content

End users

readme.md

Installation, configuration, usage.

Developers

Python docstrings

Design decisions, trade-offs, “why” explanations.

Workflow maintainers

YAML comments

Brief “what” + pointer to Python code for “why.”

Bug reporters

.github/ISSUE_TEMPLATE/

Reproduction steps, version commands.

Contributors / Claude

CLAUDE.md

Conventions, policies, non-obvious rules.

YAML to Python distillation: When workflow YAML files contain lengthy “why” explanations, migrate the rationale to Python module, class, or constant docstrings (using MyST admonition fences like ```{note} and ```{warning}). Trim the YAML comment to a one-line “what” plus a pointer.

Example data

Invented example data (docs, docstrings, comments, test fixtures) must be domain-neutral: cities, weather, fruits, animals, recipes. Do not reach for software-engineering or packaging vocabulary for a placeholder, and never invent a plausible-looking package or manager name: this project’s whole domain is package metadata, so a made-up foo-lib 1.2.3 in a docstring is indistinguishable from a real fixture and will eventually be read as one.

The exception is the material that must be real to be correct: the [samples] fixtures of the bundled TOML definitions and the shell-session blocks harvested into the docstring corpus are captured CLI output, held to byte-accuracy by test_documented_output_still_parses. Those are not examples, they are data.

Changelog and readme updates

Always update documentation when making changes:

  • changelog.md: Add a bullet point describing what changed (new features, bug fixes, behavior changes), not why. Keep entries concise and actionable. Justifications and rationale belong in documentation or code comments, not in the changelog.

  • readme.md: Update relevant sections when adding/modifying public API, classes, or functions.

Changelog scope tags. Every bullet opens with a comma-separated [scope] tag, alphabetically sorted and deduplicated, drawn from the pool manager IDs plus the platform IDs and mpm, bar-plugin, gnome-shell. test_changelog enforces that vocabulary, and it is not cosmetic: manager_changelog() indexes the tags to build the release-history section of every manager page. Two consequences. A tag naming a manager must describe mpm’s support for that manager, so work on mpm’s own downstream package for a channel that shares a manager’s name (Guix, Nix, MacPorts, Alpine) is scoped [mpm] like the rest of the packaging work, never to the manager whose page it would otherwise land on. And a new manager needs its changelog entry, since test_manager_changelog_entries asserts every pool manager has one.

Benchmark page (docs/benchmark.md)

The benchmark compares mpm against related tools. It mixes one generated table with several hand-maintained ones, and its cells follow strict evidence rules.

Generated vs hand-maintained. Only the “Package manager support” table is generated: it renders live at Sphinx build time through the {python:render} block in docs/benchmark.md, which calls benchmark_managers_table() from meta_package_manager/_docs.py, fed by docs/benchmark.yaml; its competitor set is the BENCHMARK_COMPETITORS tuple. Every other table (Features, Operations, OS, Distribution, Activity, Popularity, Metadata) is edited by hand. The block carries the :mirror: flag: a generated copy of the table is checked in right below the fence, between <!-- mirror -->/<!-- mirror-end --> markers, so it is reviewable in raw diffs and renders on GitHub. Never hand-edit the mirrored region: click-extra refresh-directives (run by repomatic’s update-docs job, or by hand from the repository root) regenerates it. Sphinx builds keep rendering the live output in memory and never read the mirror, so the published table cannot drift even when the checked-in copy is stale; the mpm-column ✅ links (class source-line anchors) are computed at render time, so the mirror legitimately churns whenever manager source lines shift. test_benchmark_table_renders guards the generator against crashes and structural regressions.

Cell glyphs. ✅/❌ are shared by the docs’ comparison and capability tables: the benchmark tables, the SBOM page’s coverage matrix and tool-comparison table, the cooldown support table and the augmentations table. Never backtick-quote a glyph, in a table cell or in prose: a glyph is not an identifier, the backticks render as a code span around a pictograph, and in the benchmark’s mpm column ✅ and ⚠️ are links, which a code span would flatten.

A dead upstream gets two glyphs, and the split is the point — it encodes whether mpm ships code for the tool:

  • ⚠️ — wrapped, but at risk. The upstream is abandoned and the manager carries the unmaintained flag, yet it stays wrapped and usable. Marks the Unmaintained column of the manager index, the same fact in readme.md’s operation matrix, and the benchmark’s mpm column where it replaces the ✅ that manager would otherwise get.

  • ☠️ — never wrapped. The upstream is dead, so mpm declined to write the manager at all. Marks the Status column of docs/unsupported.md and the benchmark’s mpm column (see the unsupported key below), alongside ❌ for a live tool declined on its own merits.

The benchmark’s mpm column is therefore a five-state scale, and the two families never mix: ✅/⚠️ link to the implementing class, ☠️/❌ to the decision not to write one, and a blank cell means the tool was never assessed.

Never swap one for the other: a reader scanning for something they can still install today needs ⚠️ to mean “works, may go away” and ☠️ to mean “was never there”. Two tables add glyphs that do not travel: the benchmark’s 🟡 for coarse support a competitor cannot invoke in isolation, and the cooldown table’s 🔜 (gate shipped upstream, not yet plugged into mpm), 🚧 (proposed upstream) and ➖ (not applicable). Only the dense per-manager operation grids keep plain : readme.md’s operation matrix and each manager page’s own operations table. The bar plugin’s ⚠️ is unrelated, counting runtime errors rather than upstream health. The evidence-link discipline below is benchmark-specific.

  • ✅ — supported. The mpm ✅ is always a link: to the manager class’s source line in the generated table, to the feature’s user documentation in the Features table, where the row’s label carries that same target so both halves of the row lead to the same page. A competitor’s ✅ links to whatever proves the support — its documentation, config example, CLI declaration, or the source line implementing it — and stays a bare glyph only when the research turned up nothing citable.

  • ❌ — not supported, and only ever written with a link to explicit, verifiable evidence that the project lacks or rejects the feature: an issue/PR closed not-planned, a maintainer “out of scope” / “won’t add” comment, a still-open unaddressed feature request, or an official doc/man-page stating the limitation. Absence of the feature is never sufficient — if no citable source exists, leave the cell blank. Verify every URL (gh issue view, gh api, or WebFetch) and keep the exact supporting quote before committing the link; prefer a precise #issuecomment-<id> anchor when a maintainer states the position. This mirrors the “Concurrent multi-PM execution” row.

  • 🟡 — coarse/bundled support the competitor cannot invoke in isolation (e.g., topgrade’s --only shell running every shell-plugin manager at once), also with an evidence link.

docs/benchmark.yaml has five alphabetically-sorted keys: managers (which competitor supports each manager), homepages (URLs for non-pool managers only), coarse_support ({manager: {competitor: url}}), refused ({manager: {competitor: url}} for competitors that explicitly declined a manager mpm wraps), and unsupported (a flat list of managers mpm itself declined). test_benchmark_yaml_well_formed enforces the shape plus the no-orphan and no-conflict invariants (a (manager, competitor) pair cannot be in both managers and refused; an unsupported manager must have a managers row and must not be in the pool).

The table is a coverage map, so a manager no tool wraps still earns a row. A competitor’s backend that mpm lacks is exactly what the table exists to surface: leaving it out hides the gap. By the same logic a retired tool is never dropped from the table — a competitor that still drives a dead tool is a fact about the competitor. The mpm column then distinguishes the two kinds of absence, which is what unsupported is for — ❌ links to the decision in docs/unsupported.md, while a blank cell means the tool was never assessed. Only settled decisions belong in unsupported: a tool framed as a not yet (the project-scoped ecosystems) keeps its blank cell, since ❌ would overstate it. The link target lives once in _docs.py’s UNSUPPORTED_DOCS_URL, never repeated per manager.

docs/unsupported.md is the user-facing record, not the reasoning. It carries the table of excluded tools and the reason per row, and nothing else. Every guideline behind it — why a dead upstream or a registry-less wrapper is disqualified, how to pick the live end of a lineage, the unattended-entry-point test, the three requirements enforced in code and their escape hatches — lives in the add-manager skill, where someone deciding whether to wrap a tool will actually read it. Keep it that way: the page is a record, and rationale added to it belongs in the skill instead.

Two groups of benchmark rows are easy to misread, and neither is a refusal. Competitors like topgrade also drive system updaters, dotfile managers and single-application updaters, which are outside mpm’s domain by definition. And mpm wraps asdf, mise and volta for what they install globally, so their rows say nothing about the per-project pinning that is the separate project-scope question.

Scope and competitor set. Feature/Operation rows cover only capabilities in mpm’s domain (cross-manager package operations, output, config, distribution). Do not add rows for a competitor’s out-of-domain features (a runtime version manager’s shims, task runner, env-var management, per-project version files). Columns are the wrapper peer group (topgrade, pacaptr, pacapt, sysget, whohas) plus brew (its Brewfile is a declarative multi-backend installer); mise/asdf were removed as out-of-scope version managers, kept only as managers mpm wraps in the generated table.

Auditing competitor cells. When (re)checking a column, research one competitor project at a time (parallel agents work well); each must verify every URL and quote and report “no evidence → blank” rather than infer a gap from absence.

Manager augmentations page (docs/augmentations.md)

Documents capabilities mpm backfills on top of native tools. Two classes: selective — only some managers need it (full upgrade --all, the synthesized orphan sweep of cleanup --orphans, exact/extended search), shown in the per-manager table — and universal — every managed tool gains it (--dry-run simulation, cross-scheme version parsing, purl identifiers, uniform sudo). The per-manager table renders live at Sphinx build time through the {python:render} block calling augmentations_table() from meta_package_manager/_docs.py, derived from the capability declarations (upgrade_all_is_synthesized(), cleanup_orphan_is_synthesized() and the search_capabilities flags in meta_package_manager/capabilities.py), so the rendered page never drifts from the code. The block carries the :mirror: flag like the benchmark table: a generated copy sits below the fence between <!-- mirror --> markers, refreshed by click-extra refresh-directives, never hand-edited. test_augmentations_table_renders guards the generator.

Per-manager pages (docs/managers/)

One documentation page per pool manager, plus the docs/managers.md hub. The invariants:

  • Stubs are generated — never hand-edit them. Each docs/managers/<id>.md is written by update_manager_stubs() in docs/docs_update.py (run by repomatic’s update-docs job), which owns the whole directory: it creates a stub per pool manager, rewrites drifted ones and deletes orphans. Adding or removing a manager needs no manual page work. test_manager_stubs_in_sync enforces byte-identity with the template.

  • A generator edit does not invalidate the Sphinx cache. Incremental builds re-read a document only when its own source file changed, and editing _docs.py (or a manager docstring, or changelog.md) leaves every stub untouched: the pages then rebuild from cached doctrees carrying the previous output. Rebuild with sphinx-build --fresh-env after touching a generator, or the local docs/_build shows work that is already done. CI is immune, building from scratch every time.

  • Headings live only in the stubs. Every section body is a {python:render} block calling a manager_* generator, and those generators must emit heading-free MyST: the directive nested-parses its output into the surrounding document, where MyST headings rely on fragile section reparenting. The MANAGER_SECTIONS tuple in meta_package_manager/_docs.py is the single source of truth for the page layout; test_manager_page_sections_render locks the heading-free invariant. A fact that fits on one line belongs in the infobox (manager_card()), not in a section: that is where the invocation plumbing went (CLI names and lookup paths, forced arguments and environment, formerly a How mpm drives <id> section), leaving only what a box cannot hold — the version probe with its transcript and regexes.

  • Generators read static declarations only — class attributes, the bundled TOML files (description comment, operation specs, [samples] fixtures), the shell-session samples documented in class/attribute docstrings (harvested via meta_package_manager.docstring_corpus, shared with the corpus round-trip test, in terminal-facing class_display_blocks form for the reference traces) the hand-curated “Supported managers” table of docs/cooldown.md, whose per-manager row manager_cooldown() extracts (keep its mpm id column in sync with the pool; a missing row degrades to a “not yet assessed” line), changelog.md, whose [scope] tags manager_changelog() indexes into a per-manager release history, and labels.py, whose MANAGER_LABELS gives the card its tracker-search link (ecosystem siblings share one label, hence one search). Never touch host-probing properties (cli_path, version, available, installed packages): the pages must be identical on any build host, which is also why the card renders a path under the builder’s home as ~-prefixed (SDKMAN resolves its search path from $SDKMAN_DIR).

  • shell-session means fixture, console means illustration. Every installed/outdated/orphans/version_regexes block written under a ```{code-block} shell-session (or pwsh-session) fence is a complete sample: it must parse through the manager’s own parser (test_documented_output_still_parses enforces it) and it renders verbatim as a reference trace, so it carries no (...) truncation marker (test_fixtures_carry_no_truncation_marker guards this; bare ... in genuine CLI output like apt’s Listing... is fine) and no shell pipe: it shows the exact argv mpm runs, not a | jq prettified view or an echo n | prompt feed (test_query_fixtures_run_verbatim guards this). A block that is not a literal fixture — a human-readable variant, an interactive prompt (sdkman’s echo n | sdk upgrade), a narrative before/after transcript — uses the non-harvested ```{code-block} console fence instead: it still renders in the API docs but never reaches the corpus or the traces. There is no central exception registry; the fence language is the whole signal.

  • Manager class docstrings render outside autodoc. manager_intro() inlines the class docstring straight into the MyST page after a {py:currentmodule} directive, so cross-references in those docstrings must be fully-qualified or module-sibling ({class}`PKG`, {meth}`Yay.cooldown_env`) — a bare class-member short ref resolves in the API docs but breaks on the manager page. A malformed fence (unclosed, or a 3-backtick fence nested inside another 3-backtick fence) garbles both pages: when a code block must nest inside an admonition, the outer admonition uses a colon fence (:::{note}). TOML managers render their file’s top description comment as the intro instead.

  • Brand marks are vendored, never hotlinked. A manager’s logo attribute (or TOML key) names an SVG under docs/assets/managers/, which docs/logos_update.py owns: it downloads them from a pinned Simple Icons release, normalizes each to a single unfilled line, and records title, brand color, source and license in logos.yaml. Run it by hand, never from CI or a docs build: committed artwork keeps builds hermetic and immune to an upstream icon removal. manager_logo() inlines the SVG into the page instead of referencing it as an image, which is what lets CSS recolor it: the marks carry no fill, so they follow currentColor on the dark theme and take their brand color on the light one, for every mark: MIN_LOGO_CONTRAST is measured and reported by docs/logos_update.py but never gates a render, since WCAG exempts logotypes and dropping pale marks back to currentColor repainted recognizable brands a flat black. Remote logo URLs were assessed and rejected: linkcheck resolves nodes.image URIs, so 75 hotlinked marks would each cost a request against a budget already throttled to about one per minute on github.com. A manager whose upstream polices its mark simply declares no logo and keeps the page’s default package glyph: Microsoft’s legal team had every Microsoft mark removed from the set in its 13.0.0 (https://github.com/simple-icons/simple-icons/issues/11236, which also auto-closes re-requests as duplicates), so vscode, winget and pwsh-gallery will never have one, and Oracle’s went the same way (https://github.com/simple-icons/simple-icons/issues/11441), taking sun-tools with it. Do not re-request those, and do not vendor the marks by hand. Twelve marks carry an attribution-bearing license, so manager_logo_credits() renders their credits from the manifest into docs/license.md: crediting them is a license condition, not a courtesy.

  • Manager IDs link to the pages. The readme operation matrix (absolute https://kdeldycke.github.io/meta-package-manager/managers/<id>.html URLs, exempted from linkcheck in conf.py), the benchmark first column (pool managers only), the augmentations table, the cooldown support table and the SBOM coverage matrix all link manager IDs to their page; home pages are listed on the pages themselves. The benchmark mpm ✅ keeps its source-line link. Prose follows the same rule: a manager named as a code span anywhere in docs/*.md links to its own page, once per paragraph, so a name repeated in the next sentence stays plain while an enumeration is uniformly linked. Three places keep the bare span: a heading, where a link would rewrite the anchor other pages cross-reference; the benchmark’s own column headers and competitor cells, which name rival tools rather than wrapped managers; and the cooldown.md cells the manager-page generators reuse verbatim, where a relative target resolves from docs/managers/ and lands nowhere.

Installation and packaging pages (docs/install.md, docs/packaging.md)

docs/install.md is for end users installing mpm: every tab of its “Installation methods” tab-set opens with commands that work today, on the reader’s machine, whatever the channel’s upstream status. Everything aimed at distribution packagers lives in docs/packaging.md: the test-suite wiring, the dependency graph and click-extra compatibility matrix, and the per-channel catalog with its build walkthroughs and packaging rationale. A channel not yet released through its distro therefore carries the condensed build recipe from its packaging.md section, copied into the tab and trimmed to the commands, followed by a {admonition} naming the upstream pull request and inviting the reader to +1 it for native inclusion. Never demote such a tab to a status line plus a pointer: a one-liner the reader cannot run yet, sending them to another page for the one they can, is the shape this rule exists to forbid. The copy is the accepted cost of that: when a channel’s build steps change, update both pages. Packaging specs and their upstream submissions cite the page URL https://kdeldycke.github.io/meta-package-manager/packaging.html, never CLAUDE.md. The end-to-end procedure for adding a channel is the playbook at docs/add-packaging-channel.md; the three-file sync it enforces is the Distributor sync rule below.

File naming conventions

Extensions: prefer long form

Use the longest, most explicit file extension available. For YAML, that means .yaml (not .yml). Apply the same principle to all extensions (e.g., .html not .htm, .jpeg not .jpg).

Filenames: lowercase

Use lowercase filenames everywhere. Avoid shouting-case names like FUNDING.YML or README.MD.

GitHub exceptions

GitHub silently ignores certain files unless they use the exact name it expects. These are the known hard constraints where you cannot use .yaml or lowercase:

File

Required name

Why

Issue form templates

.github/ISSUE_TEMPLATE/*.yml

.yaml is not recognized for issue forms

Issue template config

.github/ISSUE_TEMPLATE/config.yml

.yaml not recognized

Funding config

.github/funding.yml

Only .yml documented; no evidence .yaml works

Release notes config

.github/release.yml

Only .yml documented

Issue template directory

.github/ISSUE_TEMPLATE/

Must be uppercase; GitHub ignores lowercase

Code owners

CODEOWNERS

Must be uppercase; no extension

Workflows (.github/workflows/*.yaml) and action metadata (action.yaml) officially support both .yml and .yaml — use .yaml.

Code style

Terminology and spelling

Use correct capitalization for proper nouns and trademarked names:

  • PyPI (not PyPi) — the Python Package Index. The “I” is capitalized because it stands for “Index”. See PyPI trademark guidelines.

  • GitHub (not Github)

  • GitHub Actions (not Github Actions or GitHub actions)

  • JavaScript (not Javascript)

  • TypeScript (not Typescript)

  • macOS (not MacOS or macos)

  • iOS (not IOS or ios)

Version formatting

The version string is always bare (e.g., 1.2.3). The v prefix is a tag namespace — it only appears when the reference is to a git tag or something derived from a tag (action ref, comparison URL, commit message). This aligns with PEP 440, PyPI, and semver conventions.

Context

Format

Example

Rationale

Python __version__, pyproject.toml

1.2.3

version = "6.1.2"

PEP 440 bare version.

Git tags

`v1.2.3`

`v6.1.2`

Tag namespace convention.

GitHub comparison URLs

v1.2.3...v1.2.4

compare/v6.1.1...v6.1.2

References tags.

GitHub action/workflow refs

`@v1.2.3`

actions/checkout@v6.0.2

References tags.

Commit messages

v1.2.3

[changelog] Release v6.1.2

References the tag being created.

CLI --version output

1.2.3

mpm, version 6.1.2

Package version, not a tag.

Changelog headings

`1.2.3`

## [`6.1.2` (2026-03-04)]

Package version, code-formatted.

PyPI URLs

1.2.3

pypi.org/project/meta-package-manager/6.1.2/

PyPI uses bare versions.

Rules:

  1. No v prefix on package versions. Anywhere the version identifies the package (PyPI, changelog heading, CLI output, pyproject.toml), use the bare version: 1.2.3.

  2. v prefix on tag references. Anywhere the version identifies a git tag (comparison URLs, action refs, commit messages, PR titles), use v1.2.3.

  3. Always backtick-escape versions in prose. Both v1.2.3 (tag) and 1.2.3 (package) are identifiers, not natural language. In markdown and in MyST docstrings alike, wrap them in single backticks: `v1.2.3`, `1.2.3`.

  4. Development versions follow PEP 440: 1.2.3.dev0 with optional +{short_sha} local identifier.

Commit messages

Default to a subject line and nothing else, when there is no context to link. A commit message is a log entry, not a design document.

  • Subject. One line under 72 characters, imperative mood, capitalized, no trailing period, every identifier backticked. Name what changed, not the category it falls in: Sync `uv.lock`, Fix `yay` cooldown overlay on Arch. Avoid the bare one-word subject (Typo, Lint, Fix): it costs the next reader a git show to learn anything. Say what the typo was in, what the lint fixed.

  • No decorative prefixes. This is not Conventional Commits: no feat:, chore:, fix:. A [bracketed] prefix is reserved for a mechanism that parses it back, and only [changelog] qualifies, matched literally by repomatic’s auto-tagging job. Do not confuse it with the [scope] tags that open every changelog.md bullet: those name a manager or platform, live in the changelog file rather than in git, and are indexed by manager_changelog(). The two vocabularies are unrelated. Never write a GitHub skip token ([skip ci] and its aliases) in any message, including a body: they match anywhere and leave a required check “Pending” rather than failing.

  • Body: link the context. Omit it when the subject says everything. Write one short paragraph when the why is not evident from the diff, and especially when the decision was made somewhere public: the upstream manager’s issue tracker, the distro packaging PR, the spec page that forced the behavior. Forges render commit messages as HTML, so a link is the cheapest route from git log to the full story. Format every cross-repository reference as [owner/repo#N](https://github.com/owner/repo/issues/N).

Never narrate the work in sequence or enumerate the files touched: git log --stat lists the files and the diff shows the order. Rationale needing more room than a paragraph belongs somewhere durable instead, per § Knowledge placement.

Comments and docstrings

  • All comments in Python files must end with a period.

  • Docstrings are written in MyST markdown: single-backtick code spans, {role} cross-references in the unprefixed form ({class}, {meth}, {func}, {attr}, {data}, {mod}, {exc}), markdown links, and backtick-fenced directives. click-extra’s myst_docstrings Sphinx extension converts them back to reST at build time, so autodoc is unaffected. Field lists (:param x:, :return:) keep their reST syntax, which passes through the conversion. A brace-bearing literal keeps reST double backticks (like ) so the converter cannot misread it as a role. The click-extra convert-to-myst command migrates legacy reST docstrings idempotently.

  • Every URL in a docstring is a link. MyST’s linkify extension is off, so a bare https://… renders as dead plain text on the manager pages and in the API docs alike. Write a titled markdown link ([`emerge(1)` man page](url)), keeping ] and ( on the same source line — a line break between them silently kills the link. A list of one reference is not a list: inline it as Documentation: [title](url). and keep the bullets for two or more. Bare URLs inside a fenced block are captured CLI output and stay untouched. The bundled TOML definitions need none of this: _toml_definition_intro() autolinks their description comments.

  • Documentation in ./docs/ uses MyST markdown format where possible. Fallback to reStructuredText if necessary.

  • Keep lines within 88 characters in Python files, including docstrings and comments (ruff default). Markdown files have no line-length limit — do not hard-wrap prose in markdown. Each sentence or logical clause should flow as a single long line; let the renderer handle wrapping.

  • Titles in markdown use sentence case.

  • Heading anchors: use the natural auto-generated anchor for cross-references; add explicit MyST anchors ((my-anchor)=) only when the natural one is unavailable (duplicate headings, non-heading targets).

  • Dataclass field docs: In dataclasses, document fields with attribute docstrings (a string literal immediately after the field declaration), not :param: entries in the class docstring. Attribute docstrings are co-located with the field they describe, recognized by Sphinx, and stay in sync when fields are added or reordered. The class docstring should contain only a summary of the class purpose.

Documenting code decisions

Document design decisions, trade-offs, and non-obvious implementation choices directly in the code using docstring admonitions (MyST fences like ```{warning}, ```{note}, ```{caution}; a colon fence :::{note} when a code block must nest inside), inline comments, and module-level docstrings for constants that need context.

__init__.py files

Keep __init__.py files minimal. They are easy to overlook when scanning a codebase, so avoid placing logic, constants, or re-exports in them. Acceptable content: license headers, package docstrings, from __future__ import annotations, and __version__ (standard Python convention for the root package). Anything else belongs in a named module.

TYPE_CHECKING block

Place a module-level TYPE_CHECKING block after all imports (including version-dependent conditional imports). Use TYPE_CHECKING = False (not from typing import TYPE_CHECKING) to avoid importing typing at runtime. See existing modules for the canonical pattern.

Only add TYPE_CHECKING = False when there is a corresponding if TYPE_CHECKING: block. If all type-checking imports are removed, remove the TYPE_CHECKING = False assignment too — a bare assignment with no consumer is dead code.

Modern typing practices

Use modern equivalents from collections.abc and built-in types instead of typing imports. Use X | Y instead of Union and X | None instead of Optional. New modules should include from __future__ import annotations (PEP 563).

Minimal inline type annotations

Omit type annotations on local variables, loop variables, and assignments when mypy can infer the type from the right-hand side. Annotations add visual noise without helping the type checker.

When to annotate: Add an explicit annotation only when mypy cannot infer the correct type and reports an error — e.g., empty collections that need a specific element type (items: list[Package] = []), None initializations where the intended type isn’t obvious from later usage, or narrowing a union that mypy doesn’t resolve on its own.

Function signatures are unaffected. Always annotate function parameters and return types — those are part of the public API and cannot be inferred.

Named constants

Do not inline a named constant during a refactor. It exists for readability and grep-ability, and in this codebase the grep is usually the point: SHARED_LOCK_FAMILIES, CANONICAL_ATTRS, MANAGER_SECTIONS and MANAGER_LABELS are each the single place a reader can enumerate a rule that is otherwise scattered across managers. When moving code between modules, carry the constant with it rather than replacing it with a literal at the call site.

Python 3.10 compatibility

This project supports Python 3.10+. Be aware of syntax features not available in Python 3.10:

  • Multi-line f-string expressions (Python 3.12+): Cannot break an f-string after { onto the next line.

  • Exception groups and except* (Python 3.11+).

  • Self type hint (Python 3.11+): Use from typing_extensions import Self instead.

Imports

  • Place imports at the top of the file, unless avoiding circular imports. Never use local imports inside functions — move them to the module level. Local imports hide dependencies, bypass ruff’s import sorting, and make it harder to see what a module depends on.

  • Version-dependent imports (e.g., tomllib fallback for Python 3.10) should be placed after all normal imports but before the TYPE_CHECKING block. This allows ruff to freely sort and organize the normal imports above without interference.

Workflow file naming

Related workflows share a prefix for visual grouping in the file listing: tests.yaml (unit/integration test suite) and tests-install.yaml (distributor installability tests). Apply the same pattern when adding new workflow files.

Workflow source URLs

Each job that tests a third-party distributor must have a comment above it with the precise URL(s) to verify the package’s status on that platform. Use the public-facing package page first (e.g., formulae.brew.sh), followed by the source definition (e.g., the GitHub-hosted formula .rb or manifest .json).

Distributor sync

docs/install.md (the “Installation methods” tab-set), docs/packaging.md (the per-channel catalog and build instructions) and .github/workflows/tests-install.yaml must stay in sync. All three carry cross-reference comments. When adding or removing a distributor, update them together: every channel gets a full install tab, whose commands are the released one-liner once the channel ships and the condensed build recipe until then.

Schedule-only workflows

Jobs that test released artifacts from external distributors (PyPI, Homebrew, Scoop, etc.) must not run on every push. They test the published version, not the code being pushed, so they belong on a schedule or manual dispatch only.

Non-interactive CI

When a third-party tool prompts interactively (path selection, asset selection), pre-create its config files and resolve inputs via gh or other CLI tools rather than piping stdin. This is more robust across platforms, especially Windows where stdin redirection often fails with “Incorrect function.”

YAML workflows

For single-line commands that fit on one line, use plain inline run: without any block scalar indicator:

# Preferred for short commands: plain inline.
  - name: Install project
    run: uv --no-progress sync --frozen --all-extras --group test

When a command is too long for a single line, use the folded block scalar (>) to split it across multiple lines:

# Preferred for long commands: folded block scalar joins lines with spaces.
  - name: Unittests
    run: >
      uv --no-progress run --frozen -- pytest
      --cov-report=xml
      --junitxml=junit.xml

Use literal block scalar (|) only when the command requires preserved newlines (e.g., multi-statement scripts, heredocs):

# Use | for multi-statement scripts.
  - name: Install Python
    run: |
      set -e
      uv --no-progress venv --python "${{ matrix.python-version }}"

YAML lines may run up to 120 characters (yamllint sets line-length: max: 120): don’t carry Python’s 88-character limit over to workflow comments or reflexively wrap them at 80.

Command-line options

Always prefer long-form options over short-form for readability when invoking commands in workflow files and scripts:

  • Use --output instead of -o.

  • Use --verbose instead of -v.

  • Use --recursive instead of -r.

The same rule applies to every argv mpm constructs at runtime: the manager commands built by the manager classes and definitions, and the sudo invocations in meta_package_manager/sudo.py (sudo --non-interactive --validate, not sudo -n -v). Long forms make the --verbosity INFO command disclosure self-documenting.

uv flags in CI workflows

When invoking uv and uvx commands in GitHub Actions workflows:

  • --no-progress on all CI commands (uv-level flag, placed before the subcommand). Progress bars render poorly in CI logs.

  • --frozen on uv run commands (run-level flag, placed after run). The lockfile should be immutable in CI.

  • Flag placement: uv --no-progress run --frozen -- command (not uv run --no-progress).

  • Exceptions: Omit --frozen for uvx with pinned versions, uv tool install, CLI invocability tests, and local development examples.

  • Prefer explicit flags over environment variables (UV_NO_PROGRESS, UV_FROZEN). Flags are self-documenting, visible in logs, avoid conflicts (e.g., UV_FROZEN vs --locked), and align with the long-form option principle.

CLI output and logging

mpm keeps two output channels distinct: the state of an operation (printed with echo) and log messages (logging, gated by --verbosity).

Verbosity tiers

The CLI defaults to WARNING (inherited from click-extra’s --verbosity default). Classify every logging call into one tier:

  • WARNING (default view): genuine problems only, such as failures with no other on-screen signal, the diagnosis tail of a failed command (its captured <stderr>, or <stdout> when that is empty; version probes and doctor exempt, see _DIAGNOSIS_EXEMPT_OPERATIONS), safety notices (cooldown safeguard skipped, a file about to be overwritten, a silent CLI call that may be hiding a sudo password prompt), the end-of-run “N managers reported errors” summary, and timeouts. Plus critical for fatal conditions. Keep it sparse.

  • INFO (narration): the operational story, like the selection summary, install/dispatch priority, per-manager announcements, discovery (X has been installed with Y), capability skips (X does not implement Y), “ignoring option …” no-ops, and every CLI invocation run on the system (the reproducible $-prompt line with forced environment variables, so the user can replay by hand what mpm does). Version-detection probes are the exception and stay at DEBUG: they are discovery, fired for every candidate manager, and would drown the narration.

  • DEBUG (technical): raw CLI output (streamed live, line by line, the manager ID glued into the level prefix as debug:<manager_id>:), version-detection probes, result refiltering, manager-selection parsing, internal data dumps. Raw output stays at DEBUG even for mutating operations, deliberately: streaming it at INFO was assessed and dropped when issue 1938 closed satisfied without it, since line-pumped output cannot faithfully reproduce raw passthrough (each \r progress redraw becomes its own prefixed line) and would swamp the narration tier. A failed run is the exception: the tail of its captured output promotes to WARNING at the failure gate (issue 1968), because a failure’s stderr is its diagnosis while a success’s stderr is chatter, and a failed mutating operation cannot be re-run at DEBUG to regenerate it. If demand appears for watching live output with concurrency (DEBUG serializes to one worker via serial_at_debug), the lever is ready: run_cli takes a per-call output_level, gated on _active_operation in CLIExecutor.run.

Heuristic for a new line: if it narrates a decision, a step, or a command run on the system it is INFO; a raw mechanism or a command’s output is DEBUG; something genuinely wrong and not already shown by the ✓/✗ trail is WARNING. “Your option had no effect here” is INFO, not WARNING.

A manager-scoped line passes extra={"label": manager.id} instead of naming the manager in the message: click-extra’s formatter renders the ID glued into the level prefix (warning:gem: Could not list installed packages.), matching the streamed CLI output lines and making logs grep-able by manager. Keep the ID in prose only where it is the object of the sentence (X has been installed with Y) or names a config artifact (No [gem] section found.).

An enum surfaced in any message must render as its bare member name: give it __str__/__format__ returning self.name. A functional Enum("Operations", (...)) otherwise leaks the Operations.outdated repr where the message wanted outdated.

Operation state: the ✓/✗ trail

Fan-out operations report state with a per-item / trail plus a persistent finisher, printed via echo to stderr, never logging. echo survives the WARNING default and is instead gated on an interactive terminal plus --progress, so pipes, CI and serialized runs stay clean.

Concurrency is decided by cross-manager ordering, not by whether a command mutates state. Three fan-out primitives, all bounded by --jobs:

  • Per manager, concurrent (meta_package_manager.dispatch.collect_from_managers, one result per manager): commands whose work is independent and reported per manager. The read-only queries (installed/outdated/search), the maintenance commands (sync/cleanup/upgrade --all, which pass report_state=True since the trail is their only output), and the inventory exporters (dump/backup, sbom, which collect concurrently then assemble in manager order).

  • Per package, concurrent across managers and serial within each (meta_package_manager.dispatch.collect_per_package, one result per (package, manager)): the ordering-free state changers remove, upgrade <packages>, restore, and the manager-tied specs of install. Managers run in parallel; one manager’s own packages run one at a time, since a manager cannot safely run two of its own invocations at once (see SHARED_LOCK_FAMILIES).

  • Sequential (OperationTrail in dispatch.py): only install when a package is left untied to a manager. Such a package needs a priority search (install with the first manager that has it, skip the rest), which is genuinely cross-manager-sequential. warn_jobs_ignored notes at INFO when an explicit --jobs is therefore ignored.

The shared-lock families that make within-family concurrency unsafe (brew/cask over Homebrew’s update lock, apt/apt-mint/deb-get over dpkg, plus the RPM and pacman families) are catalogued in dispatch.py’s SHARED_LOCK_FAMILIES. The mutating fan-outs enforce them: merge_into_lock_lanes collapses each family into one dispatch lane, so its members run serially (one shared backend lock, never raced) while distinct families still run in parallel. The read-only queries take no backend lock and keep one lane per manager. A family lane also shares a command cache (CLIExecutor.run_cache), so members resolving to a byte-identical invocation (brew/cask both running brew update for sync) run the subprocess once. Adding a newly-conflicting set is a one-line edit: append a frozenset of ids to SHARED_LOCK_FAMILIES.

Trail conventions:

  • Two shapes: package-keyed ( foo installed with brew, for install/remove/upgrade <packages>/restore) and manager-keyed ( brew, Synced N/M managers, for sync/cleanup/upgrade --all). cleanup suffixes each manager line with the categories dispatched to it ( brew (cache)), since the per-manager subsets differ.

  • The finisher counts per (package, manager) attempt, matching the trail lines: a package acted on by two managers is 2/2, not 1/1.

  • A line is TTY-only, so failures also emit a critical: Could not ... (shown everywhere) as the durable record and the non-zero-exit rationale. Keep both despite the overlap on a TTY.

Exit codes

Action commands (install, remove, upgrade <packages>, restore) collect per-package failures and exit non-zero with a critical: summary. -0/--zero-exit opts out of that gate (see exit_on_failures in cli.py): the summary still prints but the exit stays 0; usage and configuration errors keep exiting 2 regardless. Maintenance commands (sync, cleanup, upgrade --all) are best-effort: they mark a failed manager but stay exit-0. doctor is the third contract: read-only, it relays each manager’s native diagnosis verbatim to stdout (the one deliberate exception to the raw-output-at-DEBUG rule, as the report is the product and cannot be parsed), reads health from the diagnostic command’s exit code alone, and exits 1 when any manager reports problems (-0 opts out).

Testing guidelines

  • Use @pytest.mark.parametrize when testing the same logic for multiple inputs. Prefer parametrize over copy-pasted test functions that differ only in their data — it deduplicates test logic, improves readability, and makes it trivial to add new cases.

  • Keep test logic simple with straightforward asserts.

  • Tests should be sorted logically and alphabetically where applicable.

  • Test coverage is measured with pytest-cov and gated by the [tool.coverage] report.fail_under ratchet, which the parallel non-destructive run of tests.yaml is the one slice expected to clear. Declare the floor there and nowhere else: a --cov-fail-under flag outranks the config, so the partial slices opt out with an explicit --cov-fail-under=0 rather than the full run passing a value. Coverage is off by default locally, since --cov is passed by the workflow rather than sitting in addopts: a focused local pytest never trips the floor, and only a deliberate local --cov does.

  • Do not use classes for grouping tests. Write test functions as top-level module functions. Only use test classes when they provide shared fixtures, setup/teardown methods, or class-level state.

  • The CLI template-class hierarchy is a deliberate exception, kept by decision. tests/test_cli.py’s CLISubCommandTests/CLITableTests/CLIQueryTests templates give each test_cli_*.py subclass a battery of inherited behavior tests (--columns projection, serialization across every format, query filtering) for the price of a subcmd fixture. Only the subclasses that assert manager selection (install/remove, upgrade, backup, restore, sbom, managers) additionally implement an evaluate_signals() strategy for check_manager_selection(): the per-subcommand selection battery itself was retired (selection is exercised once, on a single subcommand, since the logic is shared), so a query-only subclass carries no evaluate_signals() and a subcommand with no subclass-specific behavior needs no test_cli_*.py file at all. Dissolving the hierarchy into a command×behavior parametrize was assessed and rejected: it would trade colocated per-command specifics for a cross-product harder to read and extend. Shared assertion logic goes on the template classes (or module helpers like check_packages_payload), while per-command parametrize data stays in the subcommand’s own file.

  • @pytest.mark.once for run-once tests. The once marker (declared in [tool.pytest].markers) tags tests that only need to run once, not across the full CI matrix. The matrix tests job filters them out with pytest -m "not once", and the once-tests job of tests.yaml runs them on a single runner. Two modules carry it today, both via a module-level pytestmark: tests/test_metadata.py (which reads pyproject.toml and the generated matrix) and tests/test_gnome_extension.py (which asserts on checked-in extension sources). The admission test is coverage, not just OS-independence: a once module must import no package code beyond __version__, so moving it off the matrix cannot lower the slice that holds the coverage floor. A test that both covers meta_package_manager and reads only static files stays on the matrix.

  • Write conformance tests when fixing a class of bugs. For a bug that is a category rather than a one-off, add a generic test locking in the invariant: enumerate every member of the set (pool managers, generators, bundled TOML files, docstring corpus entries) and assert the property uniformly, failing with the violator’s name. This is why test_content_order, test_manager_changelog_entries and test_documented_output_still_parses exist. Applies when the bug stems from a shared convention checkable from the codebase alone, with no fixtures or mocks.

  • CI-only pytest flags belong in workflow steps, not [tool.pytest].addopts. Flags that emit CI-only artifacts (--cov-report=xml, --junitxml=junit.xml) pollute local runs when placed in addopts: keep addopts for flags that apply everywhere and pass CI-specific ones in the workflow run: step. Coverage settings (run.branch, run.source, report.precision) belong in [tool.coverage], not in --cov-* flags.

  • Pass encoding="UTF-8" to subprocess.run(..., text=True) when output may contain non-ASCII bytes (emoji in a workflow name:, accented author names, translated strings). text=True alone decodes with the platform default (cp1252 on Windows), so such output raises UnicodeDecodeError only in Windows CI while passing on macOS and Linux. Test helpers shelling out to package managers or git are the usual offenders.

  • Pass an explicit encoding to every text-mode open(), read_text() and write_text() in tests, same as production. The same Windows cp1252 default applies to file I/O, and the failure stays hidden until the content grows its first non-ASCII character, which manager output and docstring samples do constantly. When a change touches file I/O, run the suite once with PYTHONWARNDEFAULTENCODING=1 (PEP 597) to surface every bare call at runtime, on any platform: a linter misses the unannotated Path locals.

  • TTY-gated output needs a pseudo-terminal to test. The / trail, finishers and spinners only render on an interactive terminal, so click-extra’s CliRunner (non-TTY) never emits them — drive the CLI under pty.openpty() to exercise them. Most CLI tests instead assert on the stdout table, exit code, or an explicit --verbosity, none of which are TTY-gated.

  • --dry-run simulates read CLIs too. It dry-runs every manager invocation, including the installed-package lookup that remove/upgrade use to find their source managers — so a dry-run of those reports “not recognized” and cannot exercise their multi-manager path. Reach for purls (which carry the manager and bypass the lookup) or unit fixtures instead.

  • --plan runs reads but captures writes. The complement of --dry-run: plan mode executes the read-only queries (so install/remove/upgrade --all resolve their real source managers and targets), then records only the state-changing commands (_MUTATING_OPERATIONS) into execution.PLAN_RECORDER and prints them to stdout at context close, without running them. force_exec reads (version probes, yarn global dir) patch plan off and run for real. Test it against real reads or purls, and assert on stdout: the plan is plain echo, not the TTY-gated trail.

  • The suite is hermetic with respect to the host mpm config. click-extra’s default --config search resolves to the host config folder (~/Library/Application Support/mpm on macOS, ~/.config/mpm on Unix). Any config.toml there would otherwise leak into every in-process CLI invocation: a local cpan = false drops the manager, so check_manager_selection assertions expecting the full default set fail locally while passing in CI. The isolate_user_config autouse fixture in tests/conftest.py repoints config discovery at an empty temp directory, so host config never reaches the suite. Tests that exercise config loading pass --config <path> explicitly, which overrides the default and is left unaffected.

Choosing test-matrix targets

repomatic metadata builds the full and PR matrices from [tool.repomatic.test-matrix.*], whose every deviation from the defaults is commented in pyproject.toml. tests/test_metadata.py turns a matrix that drifts from requires-python into a failing check. The selection conventions:

  • Cover the shipped config broadly, probe unreleased axes narrowly, and let the OS spread pay for itself. Released dependencies on stable Python get the full cross-platform spread, since mpm drives a different set of managers on each OS and that spread is the product. Prerelease Python (3.15) keeps repomatic’s unstable flag and runs on one runner; a released free-threaded build (3.14t) runs stable, also on one runner, because interpreter-level compatibility is OS-independent.

  • Pin the dependency floor, and any release a workaround targets. The floor of a supported range belongs in the matrix as an explicit value, along with any mid-range release a shim works around: that is the version that catches the shim regressing.

  • Select runners by measured speed and workload, not architecture. Where one fast runner suffices, ubuntu-26.04-arm is the fastest and cheapest tier (upstream measured ARM Linux 2-3x faster than the retired lean x86 image) and hosted macOS bills about ten times Linux, so macOS and Windows cells are reserved for the manager coverage only they add. remove.os drops the slower twin of an OS pair. Every runner literal stays within repomatic’s curated axes (KNOWN_RUNNERS): lint-repo flags anything outside them, and actionlint validates the labels themselves (see the [tool.actionlint] stopgap for the Ubuntu 26.04 preview pair). The one deliberate exception is check-void-deps.yaml on ubuntu-22.04, pinned by its apparmor comment.

Design principles

Keep logic in Python, not workflow YAML

Push anything beyond trivial wiring out of workflow YAML and into the package or its tests. Rather than duplicating an if: condition across steps, compute it once and reference the result. Rather than asserting a project invariant with grep in a run: block, write the test: tests/test_docs.py and tests/test_metadata.py hold contracts that a shell one-liner would have expressed worse and silently stopped checking. A tested generator that fails loudly beats a static artifact that can drift.

The corollary bounds how much a workflow may know: tests-install.yaml is long because each distribution channel genuinely needs its own install incantation, not because logic accumulated there.

Defensive workflow design

GitHub Actions workflows face race conditions, eventual consistency and partial failures, and this project adds a second layer of flakiness on top: every job drives real package managers against live third-party feeds. Prefer belt-and-suspenders, several independent correctness mechanisms over one guarantee. When a step depends on external state (a CDN, an upstream release, a snap store), add a retry or a graceful default and say in a comment what transient failure it absorbs. The choco upgrade all and snap install code steps of tests.yaml are the models.

Distinguish absorbing a flake from hiding a failure: a forced exit 0 belongs on setup that is best-effort by nature, never on the assertion the job exists to make.

Single source of truth for defaults

Every configurable default lives in exactly one place, and all other code derives it rather than repeating the literal. When adding one, grep for the value and point every other occurrence at the source. The cases already carrying this weight are worth knowing, since each has a test holding it: the coverage floor in [tool.coverage] report.fail_under, the cooldown window in [tool.repomatic] minimum-release-age, the manager pool in meta_package_manager/pool.py, and the page layout in MANAGER_SECTIONS.

Linting and formatting

Linting and formatting are automated via GitHub workflows. Developers don’t need to run these manually during development, but are still expected to do best effort. Push your changes and the workflows will catch any issues and perform the nitpicking.

Ordering conventions

Keep definitions sorted for readability and to minimize merge conflicts:

  • Workflow jobs: Ordered by execution dependency (upstream jobs first), then alphabetically within the same dependency level.

  • Python module-level constants and variables: Alphabetically, unless there is a logical grouping or dependency order. Hard-coded domain constants should be placed at the top of the file, immediately after imports. These constants encode domain assertions and business rules — surfacing them early gives readers an immediate sense of the assumptions the module operates under.

  • Manager class members: The canonical declaration order (identity, escalation policy, requirement, CLI plumbing, version probe, toggles, then methods in base-class order) is the CANONICAL_ATTRS tuple in tests/test_managers.py, enforced by test_content_order. Manager-specific constants (the _*_REGEXP parsers) conventionally sit between the attributes and the operations.

  • YAML configuration keys: Alphabetically within each mapping level.

  • Documentation lists and tables: Alphabetically, unless a logical order (e.g., chronological in changelog) takes precedence.

Prefer uv over pip in documentation

Documentation and install pages must use uv as the default package installer. When showing how to install the package, use uv tool install (for CLI tools) or uv pip install (for libraries/extras). Alternative installers (pip, pipx, etc.) may appear as secondary options in tab sets or dedicated sections, but uv must be the primary/default command shown.

Idempotency by default

Workflows and CLI commands must be safe to re-run. Running the same command or workflow twice with the same inputs should produce the same result without errors or unwanted side effects.

In practice: use --skip-existing, check for existing state before writing, prefer upsert semantics, make file-modifying operations convergent.

Issue and PR labelling

The content and file rules generated into pyproject.toml from meta_package_manager/labels.py only pre-label a freshly filed issue or PR: they save the maintainer a first pass, never replace the manual review and classification, and nothing downstream treats them as authoritative. Tune for precision, not recall — encode a rule only when the signal is unambiguous, and none when it is not (that manager is then labelled by hand).

  • Content rules come only from MANAGER_CONTENT_KEYWORDS: ecosystem, distro, language or brand names that unambiguously name the manager and never appear in mpm’s own output. Never the manager ID or a CLI name — mpm prints those for every installed manager (the <id> trail, the <id>: <count> summary, the managers table), so a pasted trace would tag the issue with every manager on the user’s system. A manager whose only name is its ID gets no content rule.

  • File rules map each manager’s own module and test paths to its label; keep them narrow enough that only that manager’s files match.

The generate_content_rules docstring covers the regex mechanics (anchoring, case-folding, why a label’s keywords are OR-joined into one pattern). This is the local instance of the labeller principle in repomatic’s claude.md.

Common maintenance pitfalls

  • Documentation drift is the most frequent issue. CLI output, version references, and workflow job descriptions in readme.md go stale after every release or refactor. Always verify docs against actual output after changes.

  • Module refactors strand fully-qualified docstring cross-refs. Moving an attribute between classes or modules (like the 7.3.0 split that moved cli_path and version onto execution.CLIExecutor) silently breaks every {attr}`x <old.path>` pointing at the old home, and the docs build only warns, never fails. After a move, grep the whole tree for the old dotted path. The same sweep rule applies when docstrings gain a new rendering surface (like the manager pages): one malformed fence, glued bullet list, or stale ref found means the whole corpus needs a sweep for that defect class, not a spot fix.

  • CI debugging starts from the URL. When a workflow fails, fetch the run logs first (gh run view --log-failed). Do not guess at the cause; when the user points to a specific failure, diagnose that exact one.

  • Trace to root cause before coding a fix. Audit a bug’s scope before writing the patch. If the same pattern appears in multiple places, fix it at the shared layer; if only one call site is affected, check whether the data is on the wrong code path before handling it where it lands.

  • Never reformat a hand-maintained table with an ad-hoc mdformat. Several tables are parsed back by tests and generators, and the parsers are written against the exact row shape checked in. test_unsupported_page_matches_benchmark matches docs/unsupported.md rows with a regex expecting single-space cell padding, so re-padding that table into aligned columns makes every row invisible and the test reports “no manager rows found” rather than a formatting complaint. The repository pins no mdformat configuration of its own (the format-markdown job upstream owns it), so a local run resolves different defaults and different plugins than CI. Edit a table row by copying the padding of the row above it, and leave the rest of the file untouched: the diff stays three lines instead of a hundred and fifty, and nothing downstream breaks.

  • Angle-bracket placeholders in bash code blocks. mdformat-shfmt runs shfmt on fenced ```bash blocks, and shfmt parses <foo>/>foo as redirection and reorders the command. Use curly braces ({foo}) for placeholders in bash examples.

  • Type-checking divergence. Code that passes mypy locally may fail in CI where --python-version 3.10 is used. Always consider the minimum supported Python version.

  • Simplify before adding. When asked to improve something, first ask whether existing code or tools already cover the case. Remove dead code and unused abstractions before introducing new ones.

  • Route through existing infrastructure, don’t bypass it. Before writing a new helper or merge function, check whether the codebase already handles the operation. A bug from data on the wrong code path is better fixed by routing it correctly than by duplicating logic at the wrong site.