Operational guide for AI coding agents (Claude Code, Cursor, Copilot, Codex,
Devin, Gemini CLI, Aider) working in this repo or invoking riskratchet
from elsewhere.
riskratchet is a Python CLI and pytest plugin that scores per-function
maintainability risk and fails CI when risk grows past a baseline. One
binary, one command, structured output.
pip install riskratchet # or: uvx riskratchet --help
riskratchet --help
riskratchet --version
riskratchet scan src --coverage coverage.json --json
riskratchet baseline src --coverage coverage.json --output .riskratchet.json
riskratchet check src --coverage coverage.json --baseline .riskratchet.json --json
riskratchet diff src --coverage coverage.json --baseline .riskratchet.json --json- Append
--jsonto any reporting subcommand for machine-readable stdout. - Append
--quiettoscanto drop the trailing summary line (pipe-friendly). - Use
diffwhen you need the full baseline comparison: regressions, improvements, new functions, removed functions, moved functions, and unchanged functions. - Use
--format githubfor GitHub Actions warning annotations and--format pr-commentfor a sticky PR comment body. - Use
--allowto suppress known generated/framework functions without excluding the whole source file from analysis. - All error and progress messages go to stderr; stdout is reserved for the payload.
- If
--coverageis omitted (or the file is missing), riskratchet runs the configured[tool.riskratchet] test_command(defaultpytest --cov --cov-branch --cov-report=json:{output} -q) and caches the result at.riskratchet/coverage.json. The cache is reused while no.pyfile under the scan paths is newer. Pass--no-auto-covto opt out (for CI pipelines that produce coverage themselves) and--allow-missing-coverageto tolerate the resulting absence onbaselineandcheck. --churn-days N(default90) sets the lookback window used for thechurncomponent. Also configurable as[tool.riskratchet] churn_window_days = N. The CLI value wins over config.- Config is discovered (since 0.2.7) by walking upward from the current
directory for the nearest
pyproject.tomlwith[tool.riskratchet](nearest wins if several ancestors define it);--configoverrides; with no match it falls back silently to the cwd. Path-resolution contract: relative config paths (paths,coverage,coverage_map,coverage_cache,baseline) anchor to the config file's directory, the auto-coverage test command runs from that directory, and report paths are relative to it — so a nested-directory run produces the same output as a root run. Explicit--coverage/ positional paths and the no-arg default stay relative to the current directory. Unknown[tool.riskratchet]keys warn on stderr but do not fail the command, and a malformedpyproject.tomlwarns and is skipped during the walk;riskratchet config validateis the strict (exit 2) gate. - When
checkexits1, a short hint is written to stderr with two escape hatches: regenerate the baseline (option 1) or loosen the per-component regression gate (option 2, only shown when at least one regression haskind == "component_regressed"). The hint is on stderr so--jsonstdout consumers are unaffected. check --fail-above N(since 0.2.8) is a no-baseline absolute gate: pass--fail-above Nand skip--baselineto fail when any current function's score exceedsN. Reports each violating function as akind: "above_threshold"regression (previous_score: null,delta: null) in the same envelope as the baseline gate, so JSON consumers and SARIF/table/markdown/pr-comment renderers work unchanged.--format pr-commentin no-baseline mode renders the regressions-only PR comment (since 0.2.8 P8); in baseline mode it renders the full diff-against-baseline PR comment as before. When both--baseline(resolved) and--fail-aboveare given, the baseline gate is authoritative and--fail-aboveis ignored with a stderr warning — for a baseline-aware absolute threshold use--fail-existing-aboveinstead. Configurable via[tool.riskratchet] fail_above = N(number in(0, 100]).- Setup errors are remediation-form (since 0.2.8). When riskratchet
cannot start work because of a setup problem — missing coverage,
missing baseline, malformed baseline, missing scan path, auto-coverage
produced nothing — it writes a multi-line stderr block in the shape
riskratchet: <headline>\n\nFix one of:\n 1. <description>\n <command>so every first failure suggests the exact command to run next. Tests that contract on this shape live intests/test_setup_errors.py; rely on the presence ofFix one of:and the remediation command string, not on the exact headline wording. - Zero-flag
scanprints a next-step footer (since 0.2.8). Whenscanruns without--quiet,--summary,--output, and the defaulttableformat applies, AND no baseline file exists at the resolved baseline path, scan appends a stdout footer pointing atriskratchet baseline(withriskratchet check --fail-above 60as the no-commitment alternative). The footer adapts to the empty state ("0 functions ... nothing to baseline yet"). JSON / SARIF / markdown / PR-comment outputs are unaffected because the gate isformat == "table".
Used by the public_surface component and emitted on every function in
scan --json / diff --json. Determined statically from the AST:
- No
__all__in module → by qualname. Leading-underscore segments are private; dunders (__init__,__call__, …) are public. - Module has
__all__as a static list/tuple of string literals → additive promotion. A top-level name in__all__is public even with a leading underscore. Omission does not demote a name that is otherwise public by naming rule. Nested segments still follow the naming rule, so_Cls.public_methodis public when_Clsis in__all__, but_Cls._helperis not. - Dynamic
__all__(augmented assignment, concatenation, multiple assignments) falls back to the naming rule.
| Code | Meaning |
|---|---|
0 |
success; for check, no regressions |
1 |
for check: at least one regression past tolerance |
2 |
usage error (missing baseline, unknown format, unknown function) |
scan, baseline, and explain never exit with 1. They exit 0 on
success and 2 on usage errors.
- JSON schemas live in
schemas/:report.schema.json(scan),regressions.schema.json(check),diff.schema.json(diff),baseline.schema.json(on-disk baseline),summary.schema.json(--summary --jsonenvelope), andconfig.schema.json(config show --json). Each is exercised against real CLI output intests/test_schemas.py. --format sarifemits a SARIF 2.1.0 log. The output referenceshttps://json.schemastore.org/sarif-2.1.0.jsonin its$schemafield; the upstream OASIS definition is the SARIF 2.1.0 spec. riskratchet does not ship a separate SARIF schema. Driver name isriskratchet; rule IDs areriskratchet.function-risk(fromscan) andriskratchet.regression(fromcheck).- Field names in our native JSON are stable within a minor version (0.x).
Additive changes (new optional fields) may land in any release; renames
or removals are called out in
CHANGELOG.mdunder a Breaking heading. - Paths in JSON output are repo-relative POSIX paths.
- Risk weights are configurable in
pyproject.tomlunder[tool.riskratchet.weights]. Any subset of the six component keys may be overridden; missing keys keep their default, and the whole vector is renormalized so the total still maps to[0, 100]. Invalid keys or negative values exit2. See the README for the default values. - Native JSON output includes
$schemaandversionfields.diff --jsonis validated byschemas/diff.schema.json.
compare, diff, and check recognize renamed/moved functions before
classifying them as new. The matcher uses six signals: body fingerprint,
signature fingerprint (parameters + decorators + return annotation), path
equality, qualname tail (last segment), component-vector cosine
similarity, and score proximity. An unambiguous match becomes
DiffStatus.MOVED; a multi-candidate cluster becomes the new
DiffStatus.AMBIGUOUS_RENAME, which surfaces in the gating block of the
PR comment and always shows up in regressions_from_diff so risk growth
isn't silently masked. New diff JSON fields: previous_targets (array),
match_confidence (number/null). Existing baselines without the new
optional signature field continue to load; new baselines start writing
it on the next riskratchet baseline run.
The weights (0.55 body / 0.20 signature / 0.10 path / 0.05 qualname-tail /
0.05 component-vector / 0.05 score) and 0.65 threshold are provisional.
They were chosen so body+any-other-signal clears the threshold and
signature-alone+path+tail+score-proximity doesn't. The empirical calibration
harness shipped in 0.2.10, but its corpus work targeted the sprawl weights,
not these rename-matcher thresholds — so the thresholds remain provisional
(a corpus of real-world renamed PRs is still future work; see
docs/riskratchet-0.2x-roadmap.md). Until then, expect occasional
ambiguity that requires reading the PR diff to resolve.
Signature-only matches are deliberately rejected. A candidate whose body fingerprint changed will not be silently reported as MOVED based on a matching signature alone — that would let a body rewrite hide behind a rename. Body fingerprint match + any one other signal is the minimum bar for an unambiguous match.
When a single coverage.json isn't possible, declare a per-prefix coverage map:
[tool.riskratchet]
paths = ["packages/alpha", "packages/beta"]
[tool.riskratchet.coverage_map]
"packages/alpha" = "packages/alpha/coverage.json"
"packages/beta" = "packages/beta/coverage.json"Or pass the same map on the CLI:
riskratchet scan packages/alpha packages/beta \
--coverage-map packages/alpha=packages/alpha/coverage.json \
--coverage-map packages/beta=packages/beta/coverage.jsonLongest matching prefix wins. The map is mutually exclusive with the
single --coverage flag. Every command now prints a diagnostic banner
to stderr summarizing the resolved root, scan paths, and coverage source
(coverage=single=<path>, coverage=map=<prefix:path,...>, or
coverage=none).
Per-package baseline vs repo-level baseline is a documentation choice,
not a code one: run riskratchet baseline once per package directory
(each with its own pyproject.toml and .riskratchet.json) for fully
independent ratchets, or use one repo-level baseline + [tool. riskratchet.groups] for partitioned reporting from a single config.
GitHub Actions runs the canonical check set. If you are unsure whether a change is safe, run the same commands the workflow runs (see the README Local development section) rather than inventing a local approximation.
The scanning commands only warn on unknown [tool.riskratchet] keys so a
config written for a newer version still runs. Teams that want a typo to fail
instead add riskratchet config validate (exit 2 on unknown keys / malformed
config / invalid values) as a one-line strict gate ahead of riskratchet check — the deliberate complement to the warn-by-default behavior.
Regenerating .riskratchet.json: do it in CI, not locally. Risk scores
depend on environment-sensitive inputs — churn uses a wall-clock git log --since window, and a few functions' coverage depends on the filesystem (e.g.
doctor.py::_find_newer_py compares file mtimes). A baseline regenerated on a
dev machine (especially macOS) therefore diverges from what the Linux regression
gate recomputes and trips it. Use the regenerate-baseline workflow
(Actions tab → Run workflow), which regenerates in the gate's own environment
and opens a PR; or, for a surgical add of a new module without disturbing
existing entries, edit only the added/removed entries by hand. Whichever path,
the regression gate (and dogfood) check out with fetch-depth: 0 so churn sees
full history — a shallow clone silently zeroes it.
Releases are cut separately from feature PRs (version bumps never ride a feature
PR — see "Do not"). The release commit lands on master and bumps, in lockstep:
pyproject.tomlversionanduv.lock(runuv lockafter the bump);- the literal pin in
tests/test_release_integrity.py; ACTION_REFinsrc/riskratchet/init.pyand theKayhanB21/riskratchet@vX.Y.Zpins inREADME.md(the Actionuses:block and the pre-commitrev:);- the
## [X.Y.Z]date inCHANGELOG.md.
tests/test_release_integrity.py enforces that ACTION_REF and the README pins
equal the package version, so a forgotten bump fails CI instead of shipping a stale
ref (the wrapper sat at v0.2.8 for four releases before this guard existed). Tag
vX.Y.Z on master; publish.yml builds and publishes to PyPI via Trusted
Publishing — there is no manual upload step.
Cross-repo tail — the Marketplace wrapper. KayhanB21/riskratchet-action is a
separate repo whose action.yml delegates to KayhanB21/riskratchet@vX.Y.Z. After
the PyPI release: bump that uses: ref to the new tag, commit to its master, tag a
new v1.0.N, and force-move the floating v1 tag to it — the Marketplace serves
the v1 tag, not master, so @v1 consumers stay on the old release until v1
moves. The wrapper's check-delegated-ref workflow turns CI red within a week if this
is skipped, but do it same-day.
uv sync
uv run ruff check .
uv run mypy src tests
uv run pytest --cov
uv run riskratchet scan src --coverage coverage.json --json
uv run riskratchet diff src --coverage coverage.json --baseline .riskratchet.json --jsonConventions:
- Python 3.10+, strict mypy, ruff format. Line length 110.
- Tests live under
tests/, mirroringsrc/riskratchet/module names. - CLI logic stays thin in
src/riskratchet/cli.py; business logic lives in the per-module files (scoring.py,engine.py,baseline.py, etc.). - Snapshot tests for renderers live in
tests/test_cli_snapshots.py. If a JSON shape changes, update the schema inschemas/and the snapshot in the same commit.
- Do not edit files under
dist/,.venv/, or any__pycache__/. - Do not change a JSON field name or remove a field without also updating
the matching schema in
schemas/and adding a Breaking entry toCHANGELOG.md. - Do not bump the package
versioninpyproject.tomlas part of a feature PR. Releases are cut separately. - Do not add color codes or progress bars to stdout. They break agent consumers that parse stdout. Use stderr.
- CLI entry (commands + dispatch only):
src/riskratchet/cli.py - Config discovery / validation / anchoring / value resolution:
src/riskratchet/config.py(since 0.2.7).cli.pyis a thin shell over it — business logic does not live incli.py. - Scoring:
src/riskratchet/scoring.py - Renderers (table / JSON / markdown / SARIF / GitHub annotations):
src/riskratchet/reporting/package (since 0.2.6) —text.py,markdown.py,json_payload.py,sarif.py,annotations.py, and sharedsummary.py. External callers import fromriskratchet.reporting; the submodule layout is internal. - Baseline I/O and comparison:
src/riskratchet/baseline/package (since 0.2.7) —io.py(JSON load/save),compare.py(thecheckgate),diff.py(full comparison),regressions.py(diff → failing regressions), and sharedclassify.py(matching ladder + component-regression policy). External callers import fromriskratchet.baseline; the submodule layout is internal. The rename matcher issrc/riskratchet/matching.py(top-level; also used byanalysis, so it intentionally does not live insidebaseline/). - Pytest plugin:
src/riskratchet/pytest_plugin.py - Schemas:
schemas/ - Snapshot tests use
syrupy(since 0.2.6). To regenerate after an intentional output change:uv run pytest --snapshot-update. Snapshots live intests/__snapshots__/. Shared in-memory fixtures are intests/reporting_fixtures.py. - Reporting layering rule (since 0.2.6): family submodules under
src/riskratchet/reporting/(text,markdown,json_payload,sarif,annotations) may only import fromsummary(the leaf), never from each other. Enforced bytests/test_reporting_layering.py. - Baseline layering rule (since 0.2.7): family submodules under
src/riskratchet/baseline/(compare,diff,regressions) may only import from the leaves (io,classify), never from each other. Enforced bytests/test_baseline_layering.py.