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.
1. .rrt/ lock-file schema and ownership
Section titled “1. .rrt/ lock-file schema and ownership”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.
2. Hooks↔CLI parser-spec sharing
Section titled “2. Hooks↔CLI parser-spec sharing”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 fulltreeNamespace with hardcodedcheck=True, strict=True, format="classic", etc.)config-validate,config-reference-check(synthesize acmd_configNamespace)changelog-lint(synthesizes acmd_changelog_lintNamespace)tag-check(synthesizes acmd_tag_checkNamespace)- the
docs-*family —docs-generate,docs-publish,docs-inject,docs-map-update,docs-map-check(each sets severalparsed.*attributes by hand after a minimal parse, rather than parsing a fullcmd_docsspec) docstring-suggest(synthesizes acmd_docs_suggestNamespace)artifacts-check,artifacts-generate,artifacts-snapshot(each setsparsed.snapshot/parsed.check/parsed.regenerate/parsed.strictby hand rather than sharingcommands/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).
3. MCP/CLI surface parity
Section titled “3. MCP/CLI surface parity”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.
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 inmcp/models.py. The CLI’scmd_bump/cmd_publish_snapshotprint human-readable output viaui/and return anintexit code. Same underlying operation, different surface-appropriate result encoding. - Scoping.
rrt_bumpaccepts an optionalgroupparameter to bump a single version group in one call — the CLI’s--groupflag does the same viaOptions, but MCP additionally always returns oneBumpGroupResultper 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 usesui/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:
pyproject.toml—[tool.rrt]package.json— top-level"rrt"keyCargo.toml—[package.metadata.rrt]or[workspace.metadata.rrt].rrt.toml—[tool.rrt].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 withrrt init(rrt bump,rrt ci-version) - run an extra
check_autodetected_version_consistency(...)guard inrrt bumpthat 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.