gerenuk
Impact-based pytest selection for Python, powered by ty-find.
A diff comes in; gerenuk run runs exactly the tests that diff impacts, and
nothing else:
$ gerenuk run -- -q
gerenuk: 5 node id(s) from 1 origin(s) in 152 ms — details: gerenuk impacted-tests
......... [100%]
9 passed in 0.02s
Why
A test selector is only as good as its notion of “reaches”. Matching names is
not one: it cannot tell two same-named symbols apart, and it cannot follow a
call through a re-export. gerenuk asks ty’s type checker instead, through
tyf, so the reference graph it walks follows Python’s actual name resolution.
Three commands, one pipeline:
changed-symbols— what the working tree changed, fromgitalone.impacted-tests— which tests reach those symbols, with the←chain that says why.run— the same walk in-process, mapped to pytest node ids, and then it becomes pytest.
The one failure mode that would make a selector worse than no selector is a
short list that quietly misses a test. So every degrade widens: anything
gerenuk cannot see through becomes verdict: run_all — run the whole suite —
rather than a confident answer.
The companion: audit
Turned around, the same reference graph answers the opposite question — what
does nothing reach? audit asks it for every symbol in a
file and reports two findings: symbols nothing references, and symbols only
tests reach.
$ gerenuk audit sample_pkg/service.py
warn sample_pkg/service.py:43 func `legacy_export` has no references
note sample_pkg/service.py:34 method `ShelterService.seniors` is referenced only from tests (1)
1 file(s), 7 symbol(s) checked — 1 warn, 1 note
It is a verifier rather than a repo-wide scanner: precise, per-file, and it names the referencing sites. Run it on what a sweep like vulture already flagged — see audit for the pipeline.
What it is not
gerenuk reports static references. Dynamic dispatch, plugin registries,
getattr lookups and __all__ re-exports are invisible to it. Selections
handle that by widening; audit findings are leads to confirm, not a delete
list.
Next
- Setup — install
gerenukand itstyfprerequisite - Commands — the full CLI surface
- How it works — the pipeline, end to end
Setup
Install
uv add --dev "gerenuk[ty]" # from PyPI (a maturin-built wheel)
uvx --from "gerenuk[ty]" gerenuk impacted-tests # one-off, no install
From a pip world, pip install "gerenuk[ty]".
What gets installed, and why
gerenuk shells out to tyf (from
ty-find), which in turn drives
ty’s language server.
| Package | How it arrives |
|---|---|
ty-find | a dependency — tyf is what gerenuk drives, so it is never optional |
ty | the [ty] extra — recommended, but see below |
ty is an extra rather than a dependency deliberately. It is a pre-1.0 type
checker that projects tend to pin for their own use, and a hard requirement in
gerenuk would compete with that pin for no benefit: when tyf finds no ty
on PATH it falls back to uvx ty. So uv add --dev gerenuk works on its own
if uv is around — the first call just pays a download.
Install the extra when you want the version pinned in your lockfile, or when
the machine running gerenuk has no network.
changed-symbols needs none of this: it uses git and nothing else.
From source:
git clone https://github.com/mojzis/gerenuk
cd gerenuk
cargo install --path .
Verify
$ gerenuk doctor
workspace: /home/you/proj
tyf: /home/you/proj/.venv/bin/tyf
If tyf lives somewhere unusual, point GERENUK_TYF at it:
GERENUK_TYF=/opt/bin/tyf gerenuk doctor
Workspace detection
By default gerenuk walks up from the current directory until it finds a
pyproject.toml, setup.py, setup.cfg, or .git. Override it with
--workspace PATH when you want to run against a project you are not standing
in.
Telling coding agents about it
Paste this into your project’s CLAUDE.md. It is deliberately two lines — the
exit codes, the run_all verdict and the static-reference caveat are all in
gerenuk --help and on this site, and an agent that needs them can read them
there:
### `gerenuk` — test selection and dead code
- `gerenuk run -- -q` runs only the tests the working tree's diff impacts
(`--dry-run` to inspect; `gerenuk impacted-tests` explains why).
- `gerenuk audit <file>` confirms a symbol vulture flagged is really unused;
its `only tests reach it` findings are ones vulture cannot produce.
Commands
The first three are one pipeline — impact-based pytest selection — and run
computes the whole of it in-process. The other two stand alone.
| Command | What it does |
|---|---|
changed-symbols | Map the working tree’s diff to the Python symbols it changed |
impacted-tests | Walk from those changed symbols to the tests that reach them |
run | Run pytest on exactly those tests |
audit | Report unreferenced and test-only symbols in the files you name |
doctor | Show the resolved workspace and tyf binary, then exit |
guide | Print agent-facing instructions: setup, triage or tune |
Global flags
| Flag | Default | Meaning |
|---|---|---|
--workspace PATH | auto-detect | Project root to resolve symbols against |
--format human|json | human | Output shape |
-v, --verbose | off | Debug logging to stderr (RUST_LOG wins if set) |
Exit codes
| Code | Meaning |
|---|---|
0 | The run completed and nothing was flagged |
1 | The run completed and findings were reported |
2 | The run could not complete — tyf missing, bad workspace, malformed output |
That table is audit’s. The split between 1 and 2 is what
makes it usable in CI: a failing check and a broken setup are different
problems.
changed-symbols and impacted-tests never return 1: their output is an
inventory, not a verdict on one. When impacted-tests cannot trust its own
answer it says so in the report (verdict: run_all) and still exits 0 —
“run everything” is a usable answer for a pre-commit hook, and failing the hook
because the analysis was inconclusive only teaches people to bypass it.
run is the exception to the table. Its own failures exit 2 as
usual, but once pytest starts the exit code is pytest’s, verbatim — that is
the hook contract. A selection that turns out to impact no tests exits 0
without spawning anything.
Prerequisites per command
changed-symbols needs only git — it parses Python with tree-sitter, so it
works in a checkout that has never had ty installed. impacted-tests needs
both git and tyf, but looks for tyf only once it knows the walk will
actually run. run needs those two plus pytest, which it resolves from
GERENUK_PYTEST, then pytest-command in pyproject.toml, then PATH.
audit and doctor need tyf on PATH. guide needs nothing at all, not
even a repository.
gerenuk changed-symbols
Map the working tree’s diff to the Python symbols it changed.
gerenuk changed-symbols [--base <REF>]
Unlike audit, this command needs only git — no tyf, no ty,
no Python environment. It is the first stage of impact-based test selection:
impacted-tests walks the reference graph from these
symbols out to the tests that reach them.
git is taken from PATH unless GERENUK_GIT points at a specific binary.
Example
$ gerenuk changed-symbols
base main (merge-base 5ddda1f)
changed symbols (2)
modified method mypkg.pipelines.enrich:Enricher.run src/mypkg/pipelines/enrich.py
added function mypkg.utils:parse_date src/mypkg/utils.py
ignored symbols (1)
modified function mypkg.daily:normalize_prices src/mypkg/daily.py (@transformation)
module-level changes (1)
mypkg.pipelines.enrich src/mypkg/pipelines/enrich.py
non-Python changes (1)
schema.sql
--format json emits the same information as a single object:
{
"base": "main",
"merge_base": "5ddda1f2cd66afdf789771b20a3bf667df78f050",
"changed_symbols": [
{
"symbol": "mypkg.pipelines.enrich:Enricher.run",
"file": "src/mypkg/pipelines/enrich.py",
"kind": "method",
"line": 42,
"column": 9,
"change": "modified"
}
],
"ignored_symbols": [
{
"symbol": "mypkg.daily:normalize_prices",
"file": "src/mypkg/daily.py",
"kind": "function",
"line": 17,
"column": 5,
"change": "modified",
"ignored_by": "transformation"
}
],
"module_level_changes": [
{
"module": "mypkg.pipelines.enrich",
"file": "src/mypkg/pipelines/enrich.py"
}
],
"non_python_changes": ["schema.sql"],
"test_files_changed": [],
"errors": []
}
kind is function, method or class; change is added, modified or
deleted; line and column are where the definition’s name starts, not its
first decorator, and both are 1-based. Every array is sorted, so two runs on the
same tree produce byte-identical output.
The schema is the interface between the phases:
impacted-tests --changed report.json replays a saved report instead of
diffing the working tree, which is why each entry carries enough to be walked
from — a definition’s exact position, and a file for every module-level change.
Every field above is required: a saved report is parsed strictly, so one that
omits column is rejected rather than half-read.
What gets diffed
The range is merge-base(HEAD, <base>) → working tree: staged and unstaged
edits together, plus untracked files that .gitignore does not cover. Taking
the merge base rather than the branch tip means work landing on main after you
branched is not attributed to you.
--base defaults to the first of origin/main, main, master that exists.
A --base you pass explicitly is used as given; if it does not resolve, the run
fails with exit code 2 rather than falling back.
How lines become symbols
Changed line ranges come from git diff -U0, so there is no context to
misattribute. Each line is mapped to the innermost definition that encloses it,
parsed with tree-sitter-python.
| Changed line | Attributed to |
|---|---|
| Anywhere in a function or method body, its signature, or its default values | that function or method |
| A decorator | the definition it decorates |
| A class body, outside any method (attributes, dataclass fields) | the class |
| Inside a nested function or closure | the enclosing named function |
| Inside a nested class | Outer.Inner, and its methods Outer.Inner.method |
| Module level — imports, constants, module-level statements | module_level_changes |
Blank lines are the one exception: they carry no meaning, and appending a function inserts two of them, so they are not treated as module-level changes. Docstring and comment edits are attributed normally — phase 1 does not try to detect semantic no-ops.
Symbols are named module.path:QualName. The module path is the unbroken chain
of directories above the file that contain __init__.py, which resolves
src-layout and flat layout identically. PEP 420 namespace packages fall back to
the file path with a leading src/ removed.
Added, modified, deleted
A symbol’s verdict comes from whether it exists on each side of the diff, not from which side the hunk touched:
| Old side | New side | Verdict |
|---|---|---|
| present | present | modified |
| present | absent | deleted |
| absent | present | added |
So deleting lines from a function that still exists is a modification — its callers were never orphaned. Deletions are found by parsing the old blob out of git, which is why a removed function can still be named.
Renames are not paired. git detects them, but a moved module is a new
module: every symbol in the old path is reported deleted and every symbol in
the new path added, so phase 2 still revisits the callers of the old name.
Files that are not analysed
Checked in this order, so the first row that matches wins:
| Kind | Where it goes |
|---|---|
Anything that is not .py, including .pyi stubs and binaries | non_python_changes |
Test files — a tests/test directory, or test_*.py / *_test.py | test_files_changed |
| Files that do not parse | errors, and the module is reported as module-level |
The order matters for the phases downstream: a non-Python file under tests/
— a fixture .json, say — is a non-Python change, which makes
impacted-tests answer run_all. A changed test file needs no symbol analysis:
it will select itself later.
Ignoring registry decorators
Registry-style functions — registered by a decorator and invoked by a runner —
are neither called directly nor covered by targeted tests, so chasing their
callers is wasted work. List their decorators in the repository root’s
pyproject.toml:
[tool.gerenuk]
ignore-decorators = ["transformation", "registry.task"]
Matching is syntactic dotted-suffix matching on the decorator expression, with or without call parentheses:
| Config entry | Matches | Does not match |
|---|---|---|
transformation | @transformation, @transformation(...), @registry.transformation | @transform |
registry.transformation | @registry.transformation, @a.registry.transformation | @transformation |
A matching decorator moves the symbol to ignored_symbols with the entry that
matched, rather than dropping it. One match is enough: a symbol carrying both a
matching and a non-matching decorator is still ignored. Ignoring applies to the
decorated definition only, never to its neighbours.
Limitation: import aliases are not resolved. from registry import transformation as t followed by @t will not match, because the check never
leaves the file’s syntax.
Exit codes
0 on any successful run, including one that reports hundreds of symbols — the
output is an inventory, not a verdict. 2 when the run could not complete: not
a git repository, or a --base that does not resolve.
gerenuk impacted-tests
Walk the reference graph from the symbols the working tree changed out to the tests that reach them.
gerenuk impacted-tests [--base <REF>] [--changed <FILE>]
[--max-depth <N>] [--max-symbols <N>] [--budget-ms <MS>]
This is the second stage of impact-based test selection.
changed-symbols answers what changed; this answers
what could break. It does not run pytest — the output is deliberately
node-ID-shaped so something else can.
It needs both git and tyf.
The verdict
Every run emits a verdict, and both kinds exit 0:
| Verdict | Meaning |
|---|---|
selected | The walk completed. impacted_tests is the answer. |
run_all | Run the whole suite. reason says why the selection is not trustworthy. |
That is the whole safety argument. A pre-commit hook that quietly under-selects is worse than no hook, so anything gerenuk cannot see through becomes “run everything” rather than a short list or a crash.
reason | Why |
|---|---|
non_python_changes | The diff touched files symbol analysis cannot read |
parse_errors | A changed file did not parse, so its symbols are unknown |
tyf_unavailable | tyf is not installed, so no reference can be resolved |
refs_failed | tyf failed or answered unparseably part-way through |
index_failed | The working tree could not be read part-way through |
max_depth / max_symbols / budget | A limit tripped before the frontier emptied |
decorator_dispatch | A changed symbol is registered by a decorator whose registrar could not be resolved, so the framework’s route to its tests is invisible. errors names the symbol and the decorator. |
non_python_changes and parse_errors are settled before tyf is looked
for, so a diff of pyproject.toml alone answers in a checkout with no ty
installed.
Example
$ gerenuk impacted-tests
base main (merge-base 57510ca)
verdict selected
impacted tests (4)
tests/test_api.py (whole file)
← sample_pkg.cli ← sample_pkg.cli:main ← sample_pkg.service:describe
tests/test_pipelines.py::test_run_describes_animals_in_order
← sample_pkg.pipelines:Enricher.run ← sample_pkg.service:describe
tests/test_pipelines.py::test_run_on_an_empty_shelter_returns_nothing
← sample_pkg.pipelines:Enricher.run ← sample_pkg.service:describe
tests/test_service.py (whole file)
← sample_pkg.cli ← sample_pkg.cli:main ← sample_pkg.service:describe
5 symbol(s) visited, 3 tyf call(s), 14 ms
That is a real run of make test-impact, which edits describe in the test
fixture. Read the ← line right to left as “the change, which reaches this,
which the test calls” — it is the why-chain. When a selection looks wrong,
the chain names the edge to blame, and tyf refs <symbol> confirms it by hand.
Both whole-file entries here are the module-level rule at work:
cli.py ends in if __name__ == "__main__": main(), so main is referenced at
module scope, which makes sample_pkg.cli itself a node — and every test file
that imports it is selected wholesale. That is the intended conservatism, and
the chain makes it legible.
--format json emits the same run as one object:
{
"verdict": "selected",
"reason": null,
"base": "main",
"merge_base": "57510ca2b98d1f9e469ed80748a73adca01f3dcc",
"impacted_tests": [
{
"file": "tests/test_api.py",
"symbol": null,
"via": ["sample_pkg.cli", "sample_pkg.cli:main"],
"origin": "sample_pkg.service:describe"
},
{
"file": "tests/test_pipelines.py",
"symbol": "tests.test_pipelines:test_run_describes_animals_in_order",
"via": ["sample_pkg.pipelines:Enricher.run"],
"origin": "sample_pkg.service:describe"
},
{
"file": "tests/test_pipelines.py",
"symbol": "tests.test_pipelines:test_run_on_an_empty_shelter_returns_nothing",
"via": ["sample_pkg.pipelines:Enricher.run"],
"origin": "sample_pkg.service:describe"
},
{
"file": "tests/test_service.py",
"symbol": null,
"via": ["sample_pkg.cli", "sample_pkg.cli:main"],
"origin": "sample_pkg.service:describe"
}
],
"test_files_changed": [],
"ignored_symbols": [],
"stats": {
"seeds": 1,
"visited": 5,
"max_depth_reached": 2,
"tyf_calls": 3,
"duration_ms": 14
},
"errors": []
}
Reading the identifiers
A symbol id is module.path:QualName. An id with no colon is a module — it
is what a reference at module scope reaches.
symbol is null when the whole file is selected rather than one test
function, which is what a module-level edge produces. In pytest terms, a
non-null symbol is a file::function node id and a null one is just the file.
via holds the symbols strictly between the test and origin, nearest the test
first. An empty via means the test references the changed symbol directly.
origin is never repeated in via; the human renderer joins them back up.
When a whole file is selected, its individually-reached tests are dropped from the list: the file entry already covers them, and emitting both would hand the same file to pytest twice.
test_files_changed passes straight through from
changed-symbols: a changed test selects itself, and
needs no walking.
How the walk works
Breadth-first over the reverse reference graph, one tyf refs frontier at a
time, on the working tree only.
Seeds are every entry in changed_symbols. ignored_symbols are never
seeded — that is what phase 1’s filter is for. Each module_level_changes entry
seeds both the module’s own top-level definitions and the module itself.
Each reference found is classified by what owns its line:
| The reference is | What happens |
|---|---|
| In a test file, inside a test function | Recorded as an impacted test. The walk stops there. |
| In a test file, at module scope | The whole file is recorded (symbol: null). |
A plain import / from … import line | Dropped. |
| Inside a definition | Mapped to that definition and expanded next round. |
| Inside a symbol carrying an ignored decorator | Recorded under ignored_symbols. Not expanded. |
| At module scope, not an import | The module is the node; test files importing it are selected. |
Dropping import lines is the single biggest precision win in the design. The usages inside an importing module show up as their own references, so the import line adds nothing — and keeping it would select every test that so much as imports the module.
Deleted symbols are the one case a type checker cannot help with: there is
no definition left to resolve references to. Those fall back to a word-boundary
textual scan of the workspace’s Python files for the bare name, and the walk
continues normally from whatever encloses each hit. This over-matches —
comments, docstrings, same-named locals — deliberately: deletions are rare per
commit, over-selection is safe, and the via chain shows what happened.
Because renames are not paired, a
moved module walks precisely on the added half and coarsely on the deleted
half.
Budgets
A pre-commit hook must never hang and never silently under-select. Three limits
back that up, each settable on the command line or in pyproject.toml:
[tool.gerenuk]
max-depth = 10 # BFS levels past the seeds
max-symbols = 500 # nodes visited
budget-ms = 30000 # wall clock; 0 disables it
A flag beats the config file, which beats the built-in default. Tripping any of
them produces run_all, with whatever was found so far still listed.
max-depth counts levels beyond the seed frontier, which is always expanded:
--max-depth 0 still resolves the changed symbols’ own references, and the
default of 10 walks ten hops out from them.
Replaying a saved report
gerenuk changed-symbols --format json > changed.json
gerenuk impacted-tests --changed changed.json
--changed walks a saved phase-1 report instead of diffing the working tree —
useful for debugging a selection, and for replaying a diff the tree no longer
has. It conflicts with --base, since the saved report already records the base
it was taken against.
Performance
One tyf refs call per BFS level — the whole frontier goes in one invocation —
against a warm ty daemon. stats.tyf_calls counts those invocations, so it is
the depth of the walk rather than its width.
The first call in a session pays ty’s cold start (roughly one to two seconds);
that cost is tyf’s, not gerenuk’s. The fixture walk above takes about 14 ms
warm.
Symbols are addressed by file:line:col position rather than by name, which is
both faster to resolve and unambiguous between same-named symbols in different
modules.
Known gaps
These are deliberate for this phase, not bugs to rediscover:
- Re-exports. A plain import line is dropped: the importing module’s own
uses of the symbol answer for themselves, and following the import too would
select every module that so much as mentions it. A renaming import
(
from x import y as z) is followed, because there the uses below sayzand a query aboutynever returns them — see ADR 0013.__all__-driven star imports and module-object attribute access are still invisible. - Module-level execution. A changed symbol called at import time of module
Mselects only the tests that importMdirectly. A non-test module that importsMand is tested elsewhere is missed. - Fixtures.
conftest.pyis not treated specially, and pytest injects fixtures by name rather than by reference, so a fixture is a dead end for the walk. Fixture-aware expansion is phase 3. - Runtime registries. Decorator registration is followed
(ADR 0012),
but a registry populated by a plain call at runtime,
getattrdispatch andglobals()lookups are not. - Non-ASCII before a definition’s name. Positions are byte columns. Every
prefix Python allows before a
def/classname is ASCII, so this is theoretical — but a wrong column resolves to no references rather than to an error.
Exit codes
0 for either verdict — run_all is an answer, not a failure. 2 only when no
verdict could be produced at all: not a git repository, or a --changed file
that cannot be read or parsed. Never 1.
gerenuk run
Run pytest on exactly the tests the working tree’s changes impact.
gerenuk run [--base <REF> | --impact <FILE>]
[--max-depth <N>] [--max-symbols <N>] [--budget-ms <MS>]
[--fallback-command <JSON_ARRAY>]
[--dry-run] [-- <pytest args>…]
This is the third and last stage. changed-symbols
answers what changed, impacted-tests answers what
could break, and this runs it. The impact report is computed in-process — one
command, no intermediate files, never a subprocess of itself.
It needs git, tyf and pytest.
Why this is a command and not a list of node ids
A selection has three possible answers, and an argument list can only express two of them:
| Outcome | pytest argv | Trap |
|---|---|---|
| run these | pytest a.py::t1 b.py | — |
| run everything | pytest | — |
| run nothing | (there is none) | an empty argv is “run everything” |
So pytest $(gerenuk print-node-ids) would invert the best case: the one diff
that impacts no tests at all would run the entire suite. The empty selection has
to short-circuit before a shell ever interpolates it.
| Verdict | What happens | Exit |
|---|---|---|
selected, non-empty | pytest <node ids> <passthrough> | pytest’s |
selected, empty | nothing is spawned, one line to stderr | 0 |
run_all (any reason) | pytest <passthrough> — the full suite, or the fallback command when one is configured | pytest’s, or the fallback’s |
The run_all row is the safety
argument carried to its conclusion: gerenuk
degrading mid-walk still produces a correct hook, just not a fast one. In a
large repository “the full suite” can be a painful default, which is what the
fallback command is for.
Example
$ gerenuk run -- -q
gerenuk: 5 node id(s) from 1 origin(s) in 152 ms — details: gerenuk impacted-tests
......... [100%]
9 passed in 0.02s
gerenuk says one line for itself and then becomes pytest — Ctrl-C, colours and the exit code are pytest’s own, because the process is pytest. The other two outcomes announce themselves the same way:
gerenuk: full suite — non-Python files changed
gerenuk: no tests impacted — nothing to run
--dry-run
Prints the decision and the exact argv, and spawns nothing:
$ gerenuk run --dry-run
decision: selected
gerenuk: 5 node id(s) from 1 origin(s) in 152 ms — details: gerenuk impacted-tests
tests/test_api.py
← sample_pkg.cli ← sample_pkg.cli:main ← sample_pkg.service:describe
tests/test_fixtures.py::test_described_marks_the_senior
← tests.conftest:described ← sample_pkg.service:describe
tests/test_pipelines.py::test_run_describes_animals_in_order
← sample_pkg.pipelines:Enricher.run ← sample_pkg.service:describe
expanded tests.conftest:described (fixture) → 1 node id(s)
argv
uv
run
pytest
tests/test_api.py
tests/test_fixtures.py::test_described_marks_the_senior
tests/test_pipelines.py::test_run_describes_animals_in_order
One argv element per line, deliberately: it is for reading, not for $(…).
When the outcome is run_all and a fallback command
is configured, the dry run says so instead, and the argv is the fallback’s:
decision: run_all
gerenuk: full suite — non-Python files changed
would exec fallback: ["/home/you/proj/scripts/pick-subprojects.sh","--from-gerenuk"] (reason: non_python_changes)
from: fallback-command in pyproject.toml
argv
/home/you/proj/scripts/pick-subprojects.sh
--from-gerenuk
--format json emits the selection as one object — the verdict and reason
carried through from the impact report, plus node_ids, expanded, dropped,
the assembled argv, and fallback:
{
"verdict": "selected",
"reason": null,
"decision": "selected",
"node_ids": [
{
"node_id": "tests/test_fixtures.py::test_described_marks_the_senior",
"via": ["tests.conftest:described"],
"origin": "sample_pkg.service:describe"
}
],
"expanded": [
{
"from": "tests.conftest:described",
"kind": "fixture",
"into": ["tests/test_fixtures.py::test_described_marks_the_senior"]
}
],
"dropped": [],
"argv": ["uv", "run", "pytest", "tests/test_fixtures.py::test_described_marks_the_senior"],
"fallback": null
}
decision is the three-way outcome (selected / run_all / nothing);
verdict is the impact report’s, unchanged. fallback is always present and
is null unless the outcome is run_all and a fallback command is
configured; then it carries what would have been exec’d — the resolved argv,
its source (flag, env or config), the reason, and the complete
payload that would have been written to its stdin:
{
"verdict": "run_all",
"reason": "non_python_changes",
"decision": "run_all",
"node_ids": [],
"expanded": [],
"dropped": [],
"argv": ["/home/you/proj/scripts/pick-subprojects.sh", "--from-gerenuk"],
"fallback": {
"argv": ["/home/you/proj/scripts/pick-subprojects.sh", "--from-gerenuk"],
"source": "config",
"reason": "non_python_changes",
"payload": {
"gerenuk_fallback_payload_version": 1,
"reason": "non_python_changes",
"report": { "base": "origin/main", "merge_base": "…", "non_python_changes": ["requirements.txt"], "…": "…" }
}
}
}
A dry run never executes the fallback, whatever the outcome.
From symbol to node id
impacted_tests[].symbol was designed to be node-id-shaped. Turning it into one
is mostly mechanical and entirely about pytest’s collection rules.
symbol: null→ the file path is the node id.tests.test_x:test_fn→tests/test_x.py::test_fn. The file is taken as-is and the qualified name’s dots become::, sotests.test_x:TestFoo.test_bar→tests/test_x.py::TestFoo::test_bar.- Parametrised tests need nothing:
file::test_fnselects every parametrisation, which is the right grain for impact selection.
The collectibility gate
The walk records the enclosing symbol of a reference, which is frequently a
helper or a fixture rather than a test. Handing pytest a non-collectible node id
is a usage error (exit 4) that fails the entire run, so each qualified name is
checked segment by segment against pytest’s defaults — test* functions,
Test* classes with no __init__:
| The symbol is | Node id |
|---|---|
| Collectible throughout | file::seg::seg |
Collectible up to a point (TestFoo.helper) | trimmed to file::TestFoo |
| Not collectible at all, and a fixture | fixture expansion |
| Not collectible at all, anything else | the whole file |
Every degrade is towards more tests. A file pytest collects nothing from at
all — __init__.py, a helper module, a conftest.py on its own — is dropped
instead, because selecting it would mean exit code 5.
pytest’s ini-level overrides (python_functions = check_*) are not read; see
Known gaps.
Fixture awareness
pytest injects fixtures by name, not by reference, so a fixture is invisible
to tyf from the consuming side — the walk dead-ends at the fixture’s
definition. Two failure modes follow directly, and both under-select silently,
which is the one thing the design promises never to do:
- A changed symbol referenced from a fixture body is recorded as
tests.conftest:shelter. That is not collectible, andconftest.pycollects zero tests, so the whole-file fallback would select nothing. - A directly edited
conftest.pylands intest_files_changed, and “the file selects itself” again selects nothing.
So gerenuk resolves the name edge itself, with tree-sitter and no tyf:
Recognising a fixture. A def whose decorators suffix-match pytest.fixture
or fixture — the same syntactic matcher ignore-decorators uses, with import
aliases deliberately unresolved. The fixture’s name is the def’s name unless a
string-literal name="…" overrides it. autouse= is recorded, and an
autouse= whose value is not a literal is read as on: the widening direction,
since an autouse fixture nothing names would otherwise reach no test at all.
Scope. A fixture in a test module is visible in that module. A fixture in a
conftest.py is visible in every test file in that directory’s subtree. A
module-level fixture shadows a conftest.py one of the same name — pytest’s own
rule — and a nearer conftest.py shadows a further one. pytest’s override
idiom, def shelter(shelter) in a nearer conftest.py, still runs the fixture
it shadows, so the shadowed one still reaches that subtree.
Consumers, within that scope:
- test functions and methods whose parameter list names the fixture;
- anything carrying
@pytest.mark.usefixtures("name")with string-literal arguments, and the methods of aTest*class so decorated; - any test whose
usefixturesarguments could not all be read —usefixtures(*NAMES)— since it may be asking for the fixture under a name gerenuk cannot see; - other fixtures whose parameters name it — followed transitively, with cycles terminating on the visited set;
- every test in scope when the fixture is
autouse=True.
Coarse fallbacks, where precision is not available:
| Situation | Selection |
|---|---|
Fixture with a dynamic name= | every test file in its scope |
Fixture with a dynamic autouse= | every test in its scope |
Test with an unreadable usefixtures argument | consumes every fixture visible to it |
A changed conftest.py | every test file in its subtree |
A conftest.py that cannot be parsed | every test file in its subtree |
An expanded entry keeps its audit trail: the fixture’s symbol id is prepended to the why-chain, so the output reads
tests/test_service.py::test_summary
← tests.conftest:shelter ← sample_pkg.service:describe
and tests.conftest:shelter is a real symbol id you can hand to tyf refs.
Changed test files, filtered
test_files_changed arrives from phase 1 unfiltered; this is where it is
resolved:
- entries the working tree no longer has → dropped;
conftest.pyentries → subtree expansion, never a node id;- anything pytest collects no tests from → dropped;
- everything else → the file is its own node id.
After mapping and expansion, whole-file entries supersede their own per-test
node ids — the same collapse the closure
applies, re-run because expansion
can introduce new whole-file entries. Superseded ids appear under dropped.
Finding pytest
First of: GERENUK_PYTEST, then pytest-command in pyproject.toml, then
pytest on PATH. The config key is an argv rather than a string, because the
common real-world value is a multi-word runner:
[tool.gerenuk]
pytest-command = ["uv", "run", "pytest"]
GERENUK_PYTEST names a single executable, matching GERENUK_TYF and
GERENUK_GIT; use pytest-command for anything with arguments.
pytest is invoked from the repository root, because the node ids gerenuk
hands it are repository-relative. Passthrough paths are interpreted from there
too, and so is pytest’s own rootdir and ini discovery — which is a difference
from running pytest yourself in a subdirectory.
Everything after -- is appended to the argv verbatim — -x, -k, -n auto,
whatever. gerenuk has no opinion about ordering, parallelism or --failed-first.
The fallback command
A run_all outcome means gerenuk could not bound the impact of the change. By
default that runs the whole suite under pytest. A repository that already has
its own way of narrowing work — a script that maps changed files to
sub-projects, say — can name it, and run execs that instead:
[tool.gerenuk]
fallback-command = ["scripts/pick-subprojects.sh", "--from-gerenuk"]
run_all then means “delegate to the fallback”; selected and nothing are
untouched, and the default pytest resolution is not
consulted at all on that path.
Configuring it
The value is an argv, never a shell string: no shell is involved, nothing is
word-split, and nothing inside an element is substituted. The first element is
resolved the way any exec’d program is — an absolute path as itself, a bare name
on PATH, and anything with a path separator in it relative to the
repository root (the directory pyproject.toml is in), never to the
directory gerenuk was run from.
Three sources, highest wins, mirroring how pytest is found:
| Source | Form |
|---|---|
--fallback-command '["scripts/pick.sh", "--from-gerenuk"]' | a JSON array of strings |
GERENUK_FALLBACK='["scripts/pick.sh", "--from-gerenuk"]' | a JSON array of strings |
fallback-command under [tool.gerenuk] | a TOML array of strings |
Absent everywhere is fine and means the default. An empty array anywhere in
the chain is a configuration error — even in a layer a higher one overrides,
and even when the outcome would have been selected. It fails at startup with
exit 2, not on the day the bail-out first happens.
The fallback runs from the repository root, like pytest. Everything after --
belongs to pytest and is not appended to the fallback’s argv: the fallback
is not pytest, and gerenuk does not know what its arguments mean.
What it receives
The fallback inherits gerenuk’s environment, plus one variable,
GERENUK_FALLBACK_REASON=<reason>, so a shell script can branch without parsing
anything. On its stdin it finds a JSON payload:
{
"gerenuk_fallback_payload_version": 1,
"reason": "non_python_changes",
"report": {
"base": "origin/main",
"merge_base": "3f2c…",
"changed_symbols": [],
"ignored_symbols": [],
"module_level_changes": [],
"non_python_changes": ["requirements.txt"],
"test_files_changed": [],
"errors": []
}
}
gerenuk_fallback_payload_versionis1. Adding a field or a reason variant keeps it; renaming or removing one bumps it.reportis thechanged-symbolsreport the run was computed from, in exactly the shape that command prints — the changed symbols with their files, the module-level changes, the non-Python changes, the changed test files and the parse errors. It isnullwhen--impactreplayed a saved impact report, since no diff was taken; a fabricated empty report would read as “nothing changed”.reasonis why the outcome isrun_all— the same valueimpacted-testsreports, as a stablesnake_casename:
reason | Meaning |
|---|---|
non_python_changes | the diff touched files gerenuk cannot reason about |
parse_errors | a changed Python file did not parse |
tyf_unavailable | tyf is not installed, so no reference can be resolved |
refs_failed | tyf failed part-way through the walk |
index_failed | the working tree could not be read part-way through the walk |
max_depth | the frontier was still growing at max-depth levels |
max_symbols | more than max-symbols symbols were visited |
budget | the wall-clock budget ran out |
decorator_dispatch | a changed symbol is dispatched by a decorator whose registrar could not be resolved |
unspecified | a replayed report said run_all with no reason |
New variants may be added; existing names are never renamed within a payload version.
The payload is delivered from a file that is already unlinked, not a pipe, so a script that never reads its stdin neither blocks nor fails, and nothing is left behind either way.
Exec semantics
gerenuk execs the fallback exactly as it execs pytest: its process becomes the fallback, and from then on the terminal, the signals and the exit code are the fallback’s own. gerenuk does not interpret, wrap or annotate any of it. Before the exec it says one line for itself:
gerenuk: full suite — non-Python files changed
gerenuk: delegating to fallback ["/home/you/proj/scripts/pick-subprojects.sh","--from-gerenuk"] (from fallback-command in pyproject.toml)
If the fallback cannot be started at all — the program is missing, or not
executable — the error names the resolved path and where it was configured,
and gerenuk exits 2 having run nothing else.
Delegation only
This is a one-way handover, deliberately. The fallback receives context and ownership of the invocation; it has no way to hand a test selection back to gerenuk. A script that computes a narrower set of tests runs them itself, and its exit code is the answer.
That keeps the contract small enough to pin: one payload, one direction, one
exit code. A two-way protocol — the fallback returning node ids for gerenuk to
run — would be a new, separately versioned mechanism with its own record, not a
widening of this one. There is likewise no per-reason routing: one command for
every run_all reason, and the script branches on reason itself if it wants
to.
Replaying a saved report
gerenuk impacted-tests --format json > impact.json
gerenuk run --impact impact.json
--impact maps a saved phase-2 report instead of walking the tree. It is parsed
strictly — every field required, unknown keys rejected — because a report that
half-parses becomes a confident selection of the wrong tests. It conflicts with
--base and the budget flags, which belong to the walk that already happened.
Exit codes
Once pytest starts, the exit code is pytest’s, verbatim: 0 green, 1
failures, and 2–5 mean what pytest says they mean. That is the hook
contract, and a fallback command inherits it: once it
starts, the exit code is the fallback’s.
Before any spawn, gerenuk’s own operational failures exit 2 as everywhere
else, and there is no ambiguity because pytest never ran. The empty selection
exits 0 — deliberately indistinguishable from a green suite, because for a
hook that is exactly what it is.
Known gaps
- Collection-convention drift. A repo overriding
python_functionsorpython_classesmakes the collectibility gate wrong in both directions. The gate mirrors pytest’s defaults; a mismatch degrades to whole-file. - Plugin-provided fixtures. A fixture living in an installed plugin, or
reached through
pytest_plugins, is invisible, and its consumers dead-end unexpanded. The walk still reaches the definition module when it is in the repo, so the miss is narrower than it sounds. usefixturesbeyond string literals — variables,pytestmarklists — is not read, so the names cannot be matched. A test whoseusefixturescall carries an unreadable argument is treated as a consumer of every fixture visible to it rather than of none, so the gap over-selects rather than missing a test. Apytestmarklist assigned at module level is a different shape and is not read at all. Ausefixturesmark on any enclosing class is read, nested classes included.- Deep fixture-override chains.
def shelter(shelter)in a nearerconftest.pyis followed, so a change to the shadowed fixture still selects the overriding subtree. The chain is followed by name through successiveconftest.pyfiles; an override that shadows without requesting the name it shadows correctly ends it. indirect=parametrisation and dynamically generated fixtures are not modelled.- Exit-code aliasing. After the exec, gerenuk cannot distinguish its own
never-happened failures from pytest’s
2. Every pre-exec failure has already exited before pytest existed, so this only matters when reading a log after the fact.
gerenuk audit
Report symbols that nothing references, and symbols only tests reach.
gerenuk audit [OPTIONS] <FILE>...
audit is the companion to the selection pipeline, not the product: the walk
that impacted-tests does already holds a resolved
reference graph, and asking it what does nothing reach? is one more query
against the same graph.
A verifier, not a repo-wide sweep
vulture is the cheap sweep for dead code: one pass over a whole project, no type checker involved, nothing to install beyond vulture itself. It works on names, though, which sets its limits — it cannot tell two same-named symbols apart, it flags dynamic and framework code that is very much alive, and it cannot tell you who references a symbol.
gerenuk audit asks ty’s type checker instead, through tyf. References
resolve the way Python resolves them, and each one comes back as a file and a
line. That costs one tyf refs call per symbol and needs tyf installed, which
is why audit takes the files you name rather than a repository — it is shaped
for confirming a specific suspicion, not for finding one.
So run them in that order:
vulture src/ # sweep: what might be dead
gerenuk audit src/suspect.py # confirm the file against resolved references
tyf refs one_symbol # or confirm a single name
# then delete
Referenced only from tests
The note severity is the finding a name-based sweep cannot produce at all:
every reference to the symbol exists, and every one of them is in a test file.
Production stopped calling it and its own tests are what keep it alive —
usually the residue of an unfinished refactor, and exactly the code that
survives a dead-code scan forever. That finding is why audit earns a place
next to a repo-wide scanner rather than deferring to one entirely.
What it cannot see
gerenuk reports static references, plus the three edges a type checker
cannot draw and gerenuk models explicitly: conftest.py fixtures, registering
decorators, and renaming imports (from x import y as z, which tyf answers
for under y while the code says z). Everything else dynamic — registries
populated at runtime, getattr dispatch, __all__ star re-exports — stays
invisible.
So findings are leads, not a delete list. Confirm with tyf refs <symbol>
before deleting anything, and see
Troubleshooting
for the shapes that are flagged but alive.
Example
$ gerenuk audit sample_pkg/service.py
warn sample_pkg/service.py:43 func `legacy_export` has no references
note sample_pkg/service.py:34 method `ShelterService.seniors` is referenced only from tests (1)
1 file(s), 7 symbol(s) checked — 1 warn, 1 note
Locations are relative to the workspace root and one-based, so they paste straight into an editor.
Severities
| Severity | Rule |
|---|---|
warn | The symbol has no references anywhere — production or test |
note | Every reference lives in a test file |
A file counts as a test when its path contains a tests/ or test/
directory, or its name matches test_*.py / *_test.py.
What gets audited
Only callable symbols — functions and methods. Within those, three groups are skipped deliberately:
- names starting with
_, including dunder methods: private by convention, or invoked implicitly by the interpreter; - classes, variables and constants: their reference patterns are noisy enough that flagging them produces more false positives than signal;
- functions carrying a registering decorator —
@app.command(),@router.get(...),@mcp.tool(). A framework holds the reference and calls them, so “no references” says nothing about whether they are dead. Flagging them means flagging every CLI command and every route in the project, which buries the findings that are real: on one project this rule alone cut 48 warnings to 5 while keeping both genuine ones. Decorators that merely wrap (@property,@staticmethod,@functools.wraps) do not count, and those functions are still audited. See ADR 0012.
The N symbol(s) checked count in the summary reports every symbol in the
outline, including the skipped ones, so you can see how much of the file the
rules actually looked at.
JSON output
$ gerenuk audit --format json sample_pkg/service.py
{
"files": ["sample_pkg/service.py"],
"symbols_checked": 7,
"findings": [
{
"symbol": "legacy_export",
"kind": "Function",
"file": "/home/you/proj/sample_pkg/service.py",
"line": 43,
"severity": "warn",
"message": "`legacy_export` has no references"
}
]
}
JSON keeps absolute paths, so downstream tools do not have to know the workspace root. Human output uses relative ones.
What counts as a reference
The symbol’s own definition does not. Neither does a mention in a docstring,
comment or string literal — ty resolves names, so those never appear.
References are classified as production or test by path, relative to the
workspace root. That relative part matters if your project itself lives under
a directory named tests/: absolute-path heuristics (including tyf’s own)
would call every file a test.
Cost
audit runs one tyf list per file plus one tyf refs per auditable symbol.
On a large module that is a lot of LSP round-trips — pass the files you care
about rather than the whole package.
gerenuk doctor
Check that the workspace and the tyf binary resolve, without running an
analysis.
$ gerenuk doctor
workspace: /home/you/proj
tyf: /home/you/proj/.venv/bin/tyf
Exits 0 when both resolve and 2 when either does not — so it works as a
cheap preflight step in CI before the real audit runs.
What it checks
- The workspace root. Either the
--workspacepath (which must exist), or the nearest ancestor of the current directory holding apyproject.toml,setup.py,setup.cfg, or.git. - The
tyfbinary.GERENUK_TYFif set, otherwise the firsttyfonPATH.
It does not start ty or make an LSP request. A green doctor means
gerenuk knows where to look; it does not prove the language server will come
up. If doctor passes but audit fails, the problem is downstream — see
Troubleshooting.
gerenuk guide
Print the instructions for one of the three moments someone — usually an agent — meets gerenuk.
gerenuk guide # let gerenuk choose
gerenuk guide setup # not wired into this repo yet
gerenuk guide triage # a run said run_all, or a selection surprised you
gerenuk guide tune # the reference: base, budgets, keys, binaries
| Topic | For |
|---|---|
setup | A repository gerenuk is not wired into yet. |
triage | Reading a report: the three outcomes and the run_all ladder. |
tune | The base ref, the budgets, [tool.gerenuk] and the binaries. |
Choosing a topic
With no topic, gerenuk reads one file, ./pyproject.toml, and prints triage
when a [tool.madoqua] table in it names gerenuk as a step — parsed rather than
grepped, so a mention in a comment or in dependencies does not count — and
setup otherwise. It never walks up, so run it at the repository root. tune
is never auto-selected: it is a reference, and nothing about a repository’s
state says “you need the reference right now”.
The first line of the output names the topic and why it was chosen:
# gerenuk guide: configured via pyproject.toml [tool.madoqua] -> triage
An explicit topic reads nothing from disk and prints # gerenuk guide: tune
instead.
What it needs
Nothing. guide is dispatched before the workspace is looked for, so it runs
in a directory that is not a repository, with no tyf, no git and no Python
environment — uvx gerenuk guide in an empty directory works. Like
changed-symbols it is an inventory, not a verdict: it exits 0, or 2 if it
could not write its output, and never 1.
Where the text comes from
The three pages under Agent guide are include_str!d into
the binary, so the site and the CLI serve the same bytes. Tests hold them to
it: every gerenuk command a guide shows is fed through the real argument
parser, every --flag it names exists on some command, every [tool.gerenuk]
key it shows is one the config deserializer accepts (and every accepted key is
named by a guide), the triage ladder names every run_all reason by the exact
label the CLI prints, and no page may exceed 60 lines — one that grows past a
screenful stops being read.
How it works
gerenuk is a test-selection pipeline, plus an auditor that reads the same
reference graph backwards.
The selection pipeline
changed-symbols parses Python itself, with
tree-sitter-python, and needs nothing but git — so the first stage works in
a checkout that has never had ty installed.
impacted-tests adds the second source: tyf,
which asks ty’s language server for the references, and does the real name
resolution. It walks that graph outwards from the changed symbols until it
reaches test code.
run adds a third source that is neither: the test files
themselves, parsed for pytest’s collection conventions and for the fixture
edges pytest resolves by name — which no type checker can follow. It then
execs pytest.
flowchart LR
H[gerenuk impacted-tests] --> I[changed-symbols<br/>git + tree-sitter]
I --> J{non-Python<br/>or unparseable?}
J -- yes --> K[verdict run_all<br/>no tyf needed]
J -- no --> L[BFS frontier]
L --> M["tyf refs file:line:col"]
M --> N{what owns the line?}
N -- import --> P[dropped]
N -- definition --> L
N -- test --> O[impacted test<br/>+ why-chain]
O --> Q[select<br/>node ids + fixtures]
K --> Q
Q --> R{anything to run?}
R -- yes --> S[exec pytest]
R -- no --> T[exit 0<br/>nothing spawned]
The audit pipeline
For audit, gerenuk does no parsing of its own at all —
it asks tyf for the file’s outline and then for each symbol’s references.
flowchart LR
A[gerenuk audit file.py] --> B[tyf list file.py]
B --> C[outline: symbols + ranges]
C --> D[tyf refs SYMBOL<br/>once per callable]
D --> E[reference lists]
E --> F[rules: unused / test-only]
F --> G[human or JSON report]
Step by step:
- Outline.
tyf --format json list <file>returns an LSP document outline: every symbol in the file, its kind, and its ranges. - Select.
analyze::auditable_symbolsflattens that outline and keeps the callable, non-underscore symbols. Methods become dotted names (ShelterService.seniors) sotyfcan resolve them unambiguously. - Resolve. One
tyf --format json refs <symbol>per selected symbol, yielding production and test reference lists. - Classify.
analyze::auditapplies the rules — see Two corrections gerenuk applies below. - Render.
report::Reportwrites human lines or one JSON object.
Two corrections gerenuk applies
tyf’s answers need two adjustments before the rules can use them.
The buckets are re-derived from paths. tyf splits references into
production and test lists using its own heuristic, which inspects the whole
absolute path. A project living under a directory named tests/ — gerenuk’s
own fixture package does — has every reference filed as a test. gerenuk
therefore re-classifies each reference itself, against a path made relative to
the workspace root first.
That only works when the lists are complete, so gerenuk calls
tyf refs <symbol> --tests --references-limit 0: --tests populates
test_references (withheld by default) and --references-limit 0 disables
truncation. When the lists still come back shorter than the reported counts,
gerenuk falls back to tyf’s own counts — imperfect buckets and all — rather
than reading a withheld list as zero references.
The definition is not a usage. tyf includes the symbol’s own definition
among the references. Counting it would mean nothing is ever unreferenced, so
gerenuk drops the reference that lands on the definition’s line.
Module layout
| Module | Responsibility |
|---|---|
cli | Argument parsing and command bodies |
tyf | Spawning tyf, decoding its JSON — one of three impure modules |
git | Spawning git — the second |
pytest | Exec’ing pytest — the third, and it only execs (ADR 0011) |
model | Wire types for what tyf emits |
workspace | Project-root detection, test-path heuristic |
analyze | The audit rules — pure, given parsed data |
diff | Unified-diff text → per-file line ranges |
pysource | Tree-sitter symbol extraction: which symbol owns a line |
modpath | File path → dotted module path |
config | [tool.gerenuk] in pyproject.toml |
changed | Diff plus sources → the changed-symbols report |
closure | BFS over the reverse reference graph — pure, behind two traits |
impact | tyf and the working tree behind those traits; the impact report |
fixtures | pytest’s fixture map and collection conventions — pure |
select | Impact report plus the working tree → pytest node ids — pure |
report | Human and JSON rendering |
tyf::Runner::run, git::Git::run and pytest::Runner::exec are the only
functions that spawn a process. Everything downstream takes already-parsed
values, which is why the analysis and rendering tests need no tyf, no ty,
no git, no pytest and no Python: the integration tests stub tyf and pytest
with shell scripts and set GERENUK_TYF and GERENUK_PYTEST, and changed’s
unit tests substitute a HashMap for the changed::Sources trait.
The third seam is a different shape from the other two, which is why it was allowed. It is exec-and-replace rather than run-and-parse: gerenuk’s process becomes pytest, so there is nothing to capture and no caller left to return to.
Why dotted names
tyf refs summary would match every summary in the project. tyf refs ShelterService.summary narrows to the method. gerenuk builds those dotted
names from the outline’s nesting, so class members are resolved correctly
without gerenuk knowing anything about Python scoping.
Troubleshooting
tyf not found on PATH
gerenuk looks for tyf on PATH unless GERENUK_TYF is set.
uv add --dev ty-find
# or
GERENUK_TYF=/path/to/tyf gerenuk doctor
`git` not found on PATH
changed-symbols looks for git on PATH unless GERENUK_GIT is set.
GERENUK_GIT=/path/to/git gerenuk changed-symbols
no Python project root above ...
Nothing above the current directory holds a pyproject.toml, setup.py,
setup.cfg, or .git. Either run from inside the project, or pass
--workspace /path/to/project.
no JSON found in tyf output
tyf answered with human text instead of a JSON payload — usually because the
symbol does not exist, or because ty failed to start and tyf reported that
in prose. Run the same query by hand to see it:
tyf --format json refs the_symbol
ty server did not start
tyf needs ty. Install it, or make sure uvx is available for tyf’s
fallback:
uv add --dev ty
The audit is slow
audit makes one tyf refs call per auditable symbol. A module with fifty
public functions means fifty LSP round-trips. Narrow the input:
gerenuk audit pkg/the_one_file.py # not pkg/*.py
tyf keeps a daemon warm between calls, so the second run on the same project
is much faster than the first.
A symbol is flagged but is definitely used
Expected, for a few known shapes:
- the caller reaches it through
getattr, a registry, or a plugin entry point; - it is re-exported via
__all__and consumed outside the project; - it is a framework hook (a pytest fixture, a Django signal receiver) invoked by name rather than by reference.
gerenuk reports static references only. Confirm with tyf refs <symbol>
before deleting.
A symbol is used only by tests and that is fine
Some helpers legitimately exist for the test suite. The rule emits a note
rather than a warn for exactly that reason — it is a prompt to look, not a
failure.
Setup gerenuk in this repository
gerenuk is not wired into this repository yet. Run everything below at the repository root, in this order.
1. Install. References resolve through tyf (from ty-find), which drives
ty’s language server; the first real run starts ty-find’s background daemon,
and later runs reuse it.
uv add --dev gerenuk ty-find pytest
2. Check the wiring. gerenuk doctor prints the workspace and the tyf
it found, and exits 2 if either is missing. Nothing is analysed.
3. Wire it into the commit hook. As a madoqua step, in pyproject.toml:
[tool.madoqua]
extend_check = [
{ name = "gerenuk", cmd = "gerenuk run -- -q", pass_files = false, timeout_s = 120 },
]
pass_files = false because gerenuk takes no file list: it diffs the working
tree against origin/main (then main, then master), not the index, so
unstaged edits and untracked files count in a hook. --base <REF> overrides
the ref. Everything after -- goes to pytest verbatim.
4. Name the pytest, only if plain pytest is not the one on PATH:
[tool.gerenuk]
pytest-command = ["uv", "run", "pytest"]
5. Verify with a real diff. With no diff at all, gerenuk run selects
nothing, spawns nothing and exits 0, which proves only that it ran. Push, so
that origin/main matches; change one symbol a test reaches; then:
gerenuk impacted-tests
Expect verdict selected and that test with its arrow chain back to the
symbol. gerenuk run --dry-run then shows the pytest argv that would follow.
A run_all on this first try is what gerenuk guide triage is for.
6. Exit codes. run returns pytest’s own code once pytest starts, 0 for
an empty selection, 2 when gerenuk could not run. impacted-tests and
changed-symbols never return 1: they are inventories, not verdicts.
audit is the one command that returns 1 for findings.
next: run gerenuk impacted-tests
Triage a gerenuk report
gerenuk run said one line on stderr before handing over to pytest, or
gerenuk impacted-tests printed a report. Read the verdict before the list.
Three outcomes.
selected: the walk completed and the listed tests are the whole answer. Each entry carries an arrow chain from the test back to the changed symbol; read it right to left. A surprise in the list is a real edge, not a guess.run_all: gerenuk could not bound the impact, so the full suite runs (or thefallback-commanddoes). The reason line says why; work the ladder below.impacted-testsstill exits0: “run everything” is an answer.- nothing:
selectedwith an empty list.runspawns nothing and exits0, indistinguishable from a green suite, which for a hook it is.
The run_all ladder. Take the first reason that matches.
non-Python files changed: the diff touched a file gerenuk cannot reason about - config, a template, data - and any test may depend on it, so no selection is trustworthy. Commit those files separately, or accept the full run;fallback-commandis the knob for repositories where the full suite is too slow.a changed file did not parse: fix the syntax error, then rerun.tyf is not available:uv add --dev ty-find, thengerenuk doctor.tyf failed during the walkorthe working tree could not be read:gerenuk -v impacted-testsprints the underlying error. Usually the daemon (tyf daemon status,tyf daemon stop) or a permissions problem.the depth limit was reached,the symbol limit was reachedorthe time budget ran out: a hub symbol. Raise the budget for this one run,gerenuk impacted-tests --max-depth 20, and read what it finds; make it policy only viagerenuk guide tune.a changed symbol is dispatched by an unresolvable decorator: a registrar gerenuk cannot see. Add the decorator toignore-decoratorsonly if the tests reach the symbol some other way; otherwise the full run is right.
JSON. --format json prints one object: verdict, reason (null when
selected), base, merge_base, impacted_tests as {file, symbol, via, origin} where symbol is null for a whole file and via is the chain,
test_files_changed, ignored_symbols, stats and errors.
Replay. Save one walk and map it more than once:
gerenuk impacted-tests --format json > impact.json
gerenuk run --impact impact.json -- -q
The saved report is parsed strictly, and a stale one selects the wrong tests,
so regenerate it after every diff. gerenuk run --dry-run prints the decision
and the exact argv without spawning anything, in either form.
Do not:
- Do not bypass the hook or drop the step to get past
run_all. The full suite is the safe answer; a skipped one is no answer. - Do not raise a budget in
pyproject.tomlto make one commit go through.
next: run gerenuk run -- -q
Tune gerenuk
Reference for the knobs. The defaults are conservative on purpose: every
degrade widens the selection and never narrows it, so tune for speed only
once a run_all reason keeps repeating.
Where the diff comes from. --base <REF> names the ref to diff against;
by default origin/main, then main, then master, whichever exists first.
The diff is taken from the merge-base, so commits already on the base do not
count, and it is the working tree that is diffed - staged, unstaged and
untracked alike. --workspace <PATH> names the project root instead of
walking up from the current directory to the nearest pyproject.toml,
setup.py, setup.cfg or .git.
Budgets. The walk stops and says run_all at whichever limit it hits
first. A flag beats [tool.gerenuk] in pyproject.toml, which beats the
built-in default.
| Flag | Key | Default | Meaning |
|---|---|---|---|
--max-depth <N> | max-depth | 10 | levels from a changed symbol out to a test |
--max-symbols <N> | max-symbols | 500 | symbols visited before giving up |
--budget-ms <MS> | budget-ms | 30000 | wall clock for the walk; 0 disables it |
[tool.gerenuk]
max-depth = 20
ignore-decorators = ["transformation", "celery.task"]
pytest-command = ["uv", "run", "pytest"]
fallback-command = ["scripts/pick-subprojects.sh", "--from-gerenuk"]
ignore-decorators: dotted names, suffix-matched syntactically, of decorators that register a function with a runner. A changed symbol carrying one is reported as ignored instead of walked; import aliases are not resolved.pytest-command: an argv, never a string, because the common value has arguments. Empty meanspytestonPATH;GERENUK_PYTESTbeats both.fallback-command: whatrunexecs instead of the whole suite onrun_all. It receives the reason inGERENUK_FALLBACK_REASONand thechanged-symbolsreport as JSON on stdin, and its exit code becomes the hook’s.--fallback-command <JSON_ARRAY>andGERENUK_FALLBACKoverride it, in that order. An empty array anywhere is an error at startup.
Binaries. GERENUK_TYF, GERENUK_GIT and GERENUK_PYTEST each name one
executable and skip the PATH lookup. tyf is looked for only once a walk
is actually needed, so changed-symbols and a run_all settled by the diff
alone work in a checkout with no ty at all.
Reading a walk. gerenuk changed-symbols is the first stage on its own:
the symbols the diff changed, from git alone. gerenuk impacted-tests
adds the walk, with --changed <FILE> to replay a saved first stage.
--format json on either is the schema the next stage reads.
Audit. gerenuk audit src/app.py reads the same reference graph
backwards for the files you name: symbols nothing references, and symbols
only tests reach. Exit 1 on findings. It is a verifier for a candidate
something else flagged, not a sweep.
next: run gerenuk run --dry-run