Skip to content
repo-release-tools
GitHub

Internal Contracts

This page documents contracts that exist in code but nowhere else — the kind of thing a new contributor has to reconstruct by reading several files at once. It complements CLAUDE.md (which covers day-to-day conventions) with the why does this work this way detail for five specific seams. Written as part of Phase 6b of the modernization plan; see analysis/the/MODERNIZATION_BRIEF.md in the repo for the full phase history.

All six lock/manifest filenames used under .rrt/ are constants owned by state.py — no command module defines its own filename string. Each lock has a path helper (docs_lock_path, health_lock_path, tree_lock_path, artifacts_lock_path, drift_lock_path, docs_map_lock_path) and, for the four with drift-detection semantics, an *_is_current() comparator that also lives in state.py.

Lock file Guarantees (what it snapshots) Invalidated by Owning command
docs.lock.toml Hash of every extracted source-doc block (docs/extractor.py output) at generation time Any tracked source file’s extracted doc block changing rrt docs generate --format toml (write), rrt docs check / rrt-docs-check hook (verify)
health.lock.toml Per-check status (ok / obsolete / warning / error) from doctor, folder, and EOL checks, merged into one namespace A check’s status regressing to a higher severity than what’s locked (improvements are silently accepted — see health_lock_is_current) rrt doctor --snapshot (write), rrt doctor --check (verify); rrt folder check --snapshot and rrt eol check --snapshot merge their own checks in without clobbering others
tree.lock.toml entry_count + a combined tree_hash of the project’s directory structure The tree hash differing from the snapshot (file/dir added, removed, or renamed) rrt tree --snapshot (write), rrt tree --check (verify); also the rrt-tree-check hook at pre-push
artifacts.lock.toml Per-file SHA-256 + size for every file matched by artifact_targets globs, plus an optional combined inputs_hash per target (for source→output staleness) A tracked file’s hash changing, a new file matching the glob, a locked file going missing, or (when inputs is configured) the input-file hash changing rrt artifacts --snapshot (write), rrt artifacts --check (verify), rrt artifacts --regenerate (runs each target’s generation command, then re-snapshots)
drift.lock.toml SHA-256 of every tracked agent-facing surface file (.claude/settings.json, .claude/hooks/*.py, .github/copilot-instructions.md, .github/instructions/*.md, .github/skills/*/SKILL.md, .github/agents/*.agent.md) Any tracked surface file’s content changing, or a tracked file appearing/disappearing rrt drift generate (write), rrt drift check / rrt-drift-check hook at pre-push (verify)
docs_map.lock.toml Per-directory hash of the generated Purpose+Tree README block emitted by rrt docs map The generated block’s content changing (new/removed files in the directory, purpose-doc edits) rrt docs map (write/update), rrt docs map --check / rrt-docs-map-check hook (verify)

Two lock files intentionally overlap in subject matter but stay separate files by design: docs.lock.toml tracks source-doc extraction blocks used by the generated docs site, while docs_map.lock.toml tracks the generated per-directory README blocks from rrt docs map — different generators, different drift surfaces, so a change to one never marks the other stale.

Note: this repo does not currently configure [tool.rrt.docs.map] in pyproject.toml, so rrt docs map / docs_map.lock.toml are inactive here even though the mechanism ships in the package. drift.lock.toml is active and regenerated by the rrt-drift-check/generate hooks.

workflow/hooks.py’s main() dispatcher used to hand-synthesize a partial argparse.Namespace for several hook subcommands, independently of the real CLI’s register() defaults — the exact bug class behind issue #140 (a flag rename in one place silently left the other’s default stale). Phase 5 of the modernization removed this for three subcommands by extracting a shared _add_*_arguments(parser) helper next to each command’s own register():

Hook subcommand Shared parser spec Helper Defined in
drift-check Yes _add_drift_lock_arguments commands/drift_cmd.py
publish-snapshot Yes _add_publish_snapshot_arguments commands/git_sync.py
folder-check Yes _add_folder_check_arguments commands/folder.py

For these three, hooks.py builds its subparser by calling the same _add_*_arguments() helper the CLI’s register() calls, then dispatches the real parsed Namespace straight to the cmd_* function — there is exactly one place flags and defaults are declared, so a flag rename can’t silently diverge between surfaces.

Subcommands that still hand-synthesize a partial Namespace (drift risk remains — the main() dispatch case block builds these directly instead of parsing real flags):

  • tree-check (synthesizes a full tree Namespace with hardcoded check=True, strict=True, format="classic", etc.)
  • config-validate, config-reference-check (synthesize a cmd_config Namespace)
  • changelog-lint (synthesizes a cmd_changelog_lint Namespace)
  • tag-check (synthesizes a cmd_tag_check Namespace)
  • the docs-* family — docs-generate, docs-publish, docs-inject, docs-map-update, docs-map-check (each sets several parsed.* attributes by hand after a minimal parse, rather than parsing a full cmd_docs spec)
  • docstring-suggest (synthesizes a cmd_docs_suggest Namespace)
  • artifacts-check, artifacts-generate, artifacts-snapshot (each sets parsed.snapshot / parsed.check / parsed.regenerate / parsed.strict by hand rather than sharing commands/artifacts_cmd.py’s parser spec)

Invariant a contributor must maintain by hand for these sites: every field the target cmd_* function reads via getattr(args, "field", default) must be set to the same default the CLI’s own register() would produce, enumerated explicitly in the case block. If commands/<x>.py’s register() changes a default or adds a new getattr(args, ...) read, the corresponding hooks.py case block must be updated in the same PR — nothing enforces this automatically for the subcommands not yet listed in the table above. Converting one of these to the shared-spec pattern is a mechanical, low-risk follow-up (see the three done sites for the pattern).

As of Phase 5, rrt_bump (MCP) and cmd_bump (CLI) call the same pipeline stage functions from commands/bump.py per version group — resolve_bump_targetapply_bump_filesupdate_changelogrefresh_bump_lockfile / refresh_bump_generated_assetsfinalize_bump_git. An MCP bump and a CLI bump of the same repo, same inputs, produce equivalent results: same files written, same changelog promotion, same lockfile refresh, same release branch + commit. This fixed the previous defect (D9) where MCP’s bump only rewrote version-target files and silently skipped pins, changelog, lockfile, generated assets, and the git branch/commit step entirely.

rrt_publish_snapshot (MCP) now requires both dry_run=False and confirm=True to force-push — if confirm is omitted or False, the call always behaves as a dry-run preview regardless of dry_run. This mirrors the CLI’s two-signal requirement (--yes-i-know-this-overwrites-remote-history and not passing --dry-run) for the same destructive, history-rewriting operation (fixes D4/D6).

Legitimate, intentional differences (API contract, not divergence):

  • Return shape. MCP tools return typed Pydantic models (BumpGroupResult, PublishSnapshotResult, one per version group for bump) defined in mcp/models.py. The CLI’s cmd_bump / cmd_publish_snapshot print human-readable output via ui/ and return an int exit code. Same underlying operation, different surface-appropriate result encoding.
  • Scoping. rrt_bump accepts an optional group parameter to bump a single version group in one call — the CLI’s --group flag does the same via Options, but MCP additionally always returns one BumpGroupResult per group processed (even on partial failure), whereas the CLI exits 1 on the first fatal error. This lets an MCP client see partial results across groups instead of an all-or-nothing exit code.
  • Progress reporting. MCP tools call ctx.report_progress() / ctx.info() / ctx.warning(), which have no CLI equivalent (the CLI uses ui/ progress bars and stdout/stderr text instead).

4. Config source precedence & auto-detection

Section titled “4. Config source precedence & auto-detection”

load_config(root) in config/core.py tries supported native config files in a fixed order — CONFIG_FILE_CANDIDATES in config/model.py:

  1. pyproject.toml[tool.rrt]
  2. package.json — top-level "rrt" key
  3. Cargo.toml[package.metadata.rrt] or [workspace.metadata.rrt]
  4. .rrt.toml[tool.rrt]
  5. .config/rrt.toml[tool.rrt] (alternate path for the same TOML shape as .rrt.toml)

Multiple sources present simultaneously: load_config iterates iter_config_files(root) — which returns only the candidates that exist — in the order above, and returns the first one whose file contains a recognized [tool.rrt] (or rrt / [package.metadata.rrt]) section. A candidate file that exists but has no rrt config (e.g. a pyproject.toml without [tool.rrt]) is skipped, not treated as an error, and the search continues to the next candidate — the “missing config” error is only raised once every existing candidate has been checked and none contained one. There is no merging across sources: exactly one file’s [tool.rrt] config is used; a second file’s config (if present) is ignored entirely.

Zero-config fallback: if no candidate file defines [tool.rrt], autodetect_config() builds a synthetic config from recognizable project files (PEP 621 [project].version, Poetry [tool.poetry].version, package.json, Cargo.toml, or multiple simultaneously-detected version files) without requiring any [tool.rrt] section at all.

The autodetected flag: every RrtConfig carries autodetected: bool. When True, it signals to callers that the config was synthesized rather than read from an explicit [tool.rrt] section, and every user-facing command that loads config checks it to:

  • print a (auto-detected) source label instead of the config file path (rrt config, rrt doctor, rrt release, rrt bump)
  • emit format_autodetected_config_notice(...) as an informational/warning line describing which version targets were inferred and how to pin them explicitly with rrt init (rrt bump, rrt ci-version)
  • run an extra check_autodetected_version_consistency(...) guard in rrt bump that fails the bump if the auto-detected version sources disagree with each other — a check that doesn’t apply to explicit config, since an explicit [tool.rrt] config declares its version targets deliberately

5. Cross-surface exit-code / output contract

Section titled “5. Cross-surface exit-code / output contract”

Exit codes. Every cmd_* function across commands/*.py returns 0 for success and 1 for failure — confirmed by spot-checking commands/bump.py, and consistent across the package (no cmd_* function returns any other integer). cli.py’s main() return value is passed straight to sys.exit(...). workflow/hooks.py’s main() follows the identical convention and is wired to the rrt-hooks console-script entry point in pyproject.toml. The one exception outside this convention is argparse itself: a malformed invocation (unknown flag, missing required subcommand) raises SystemExit(2) from argparse before any cmd_*/hook handler runs — this is Python’s argparse default and is not overridden.

Output streams. ui/messaging.py’s printer methods (DryRunPrinter, the hooks-internal VerbosePrinter, and the free functions in ui/color.py/ui/messaging.py such as error()/warning()/info()) default to stdout for normal/status output and only go to stderr when a call site explicitly passes stream=sys.stderr. The rrt-ux-contract convention (see the rrt-ux-philosophy skill) is: failure/error lines go to stderr, informational and success lines go to stdout — call sites are responsible for passing stream=sys.stderr at the point they render an error, the printer classes don’t infer it from ok=False automatically. ui/color.py’s free-standing error() function does default its own stream parameter to sys.stderr when none is given — the two conventions (hook printers vs. the color-level error() helper) agree on the outcome (errors on stderr) but not on where the default lives, so a new call site that wants stderr must pass it explicitly when going through DryRunPrinter/ VerbosePrinter.

emit_failure (hooks.py). emit_failure(title, details) -> int is the canonical way a hook handler reports a hard failure: it prints title via VerbosePrinter.line(..., ok=False, stream=sys.stderr) (error glyph + error color, forced to stderr), prints each details entry via .action(..., stream=sys.stderr), and unconditionally returns 1. Hook handlers that want the standard failure rendering call return emit_failure(...) directly; only run_changelog_check and a few other run_* validators construct their own output and return their own 0/1 without going through emit_failure. There is no equivalent single helper on the CLI side — CLI commands build their failure output ad hoc using the same ui/ primitives.

Badge families are intentionally complete for the current icon registry across platform, registry, and language labels; see src/repo_release_tools/tools/platform.py if you think one is missing.

Chat is powered by Context7, a third-party service with its own terms and privacy policy.