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_target → apply_bump_files → update_changelog → refresh_bump_lockfile / refresh_bump_generated_assets → finalize_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.

Every bump knob carries the same name, values and default on both surfaces (decision D-4). The pre-release base is the first: [tool.rrt] prerelease_base, the CLI’s rrt bump rc --base minor and MCP’s rrt_bump(level="rc", base="minor") all accept patch, minor, major or auto. With no base given, both fall back to the config, whose default is the next patch. Both resolve it through resolve_prerelease_base, so auto reads the same commits on either surface.

The version scheme follows the same rule. [tool.rrt] version_scheme, the CLI’s rrt bump --scheme pep440 and MCP’s rrt_bump(scheme="pep440") all accept semver, pep440 or calver, validated against version.scheme.VERSION_SCHEMES. With no scheme given, both fall back to the group’s config; unset there, the scheme is inferred from the primary target. An unknown value is a config error in [tool.rrt], an argparse error on the CLI and {"error": "scheme must be one of: ..."} on MCP.

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.

Some docstrings are the product. Module docstrings and module-level doc constants in this package are not internal comments. docs/publisher.py renders them into published pages under docs/src/content/docs/, so editing one changes the website. A doc reaches publication by one of two routes:

  1. an explicit SOURCE_OWNED_TOPIC_DOCS tuple exported by the module, whose slug is then wired into TOPIC_PAGE_OUTPUTS or COMMAND_DOC_SOURCES
  2. the inspect.getdoc() fallback in render_command_docs, which picks up any command module that has no explicit entry

Both routes are governed. 19 declared entries plus 20 fallback modules cover 34 distinct documents, because five docs (artifacts, docs, fields, install, skill) are reachable both ways and are validated once.

The skeleton. docs/skeleton.py enforces these rules whenever [tool.rrt.docs.skeleton] is configured:

Rule Detail
Required sections ## Overview, ## Examples, ## Caveats, ## Related docs, spelled exactly
Opening ## Overview is the first H2
Closing ## Caveats then ## Related docs are the last two H2s
Optional sections permitted only between Overview and Caveats
Non-emptiness every required section needs content; a fenced code block counts
Heading depth H3 maximum
H1 ownership forbidden for slugs whose page lives under commands/, where _ensure_primary_h1 injects one from TITLE_OVERRIDES; required elsewhere
Canonical source a substantive __doc__ may be extended or re-titled by a constant, never replaced
Collection coverage every module declaring SOURCE_OWNED_TOPIC_DOCS must appear in publisher’s collection tuple
Register per-section mean ≤ 22 words/sentence, ≤ 3.5 em-dashes per 100 words

Errors surface in layers. Missing-section errors are reported first, and ordering is only checked once all four sections exist. Adding the last missing section can therefore reveal a new ordering error on the same file.

“Substantive” means more than one paragraph. A one-line summary docstring is a summary, not documentation, so a constant may replace it freely. Anything that continues into a second paragraph is an explanation, and an explanation that never reaches the page is content no reader will see. A module whose docstring is deliberately internal opts out with the rrt:docs-exempt marker, the same marker tests/commands/test_commands_docstrings.py uses. integrations/mcp_server.py is the current example: its docstring explains module placement and import discipline, and MCP_SERVER_DOC is the published text.

Register measurement is deliberately narrow. Only narrative paragraphs count. Headings, fenced code, tables, blockquotes and list items are excluded, because they are routinely sentence fragments and averaging them alongside prose is what makes naive readability metrics meaningless on reference documentation. Sections with fewer than four prose sentences are skipped entirely, since a mean over three fragments is noise. The 22-word ceiling was calibrated against this repo’s own corpus, whose per-section distribution runs median 11.8 and p95 20.5, with one outlier at 26.0.

Two validators, different jobs. skeleton.validate_skeleton reads the source doc before headings are re-levelled, so it can name the module and line a contributor must edit. publisher.validate_generated_page reads the rendered page and owns only page-level invariants: frontmatter, exactly one H1, and headings clipped at the H6 cap. It deliberately does not look for sections, because generate_group_reference_markdown splices several commands into one page and the renderer no longer knows where one module’s block ends.

Where it runs. Both feed _cmd_publish’s consistency_issues list, which writes to stderr and returns 1 before any file is written. rrt docs publish --check is already a CI gate in cicd.yml and pages.yml, so the skeleton is enforced in CI with no workflow changes. There is no rrt-hooks subcommand and no action.yml input for it; rrt-docs-publish already covers it.

Unconfigured means off. validate_skeleton returns [] when DocsConfig.skeleton is None. This repo opts in with a bare [tool.rrt.docs.skeleton] table, since the dataclass defaults are its own contract. Expressing “off” as an empty required_sections tuple is wrong — it conflates an absent table with a deliberately disabled one.

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.