A bug nobody can reproduce in ten lines is a bug nobody will fix.
Point carve at a directory and a command that fails. It hands back the
smallest set of files — and the smallest set of lines inside them — that still
fails the exact same way.
carve -- pytest tests/test_upload.pyEverything in ./carve-out still fails. Everything deleted is now proven not
to matter. Your own directory is never written to.
A 28-file, 7,929-line Python project with one bad helper buried in it, and a test suite that catches it:
$ carve ~/code/strata -- python -m unittest discover -s tests
files 28 → 2 93% gone
lines 7,929 → 8 99% gone
bytes 950.6 KiB → 287 B 99% gone
382 runs · 262 cached · 13.6 sWhat survived, in full:
# strata/complexity.py
def _worst_line(scores):
ordered = sorted(scores)
return ordered[len(ordered) - 1]
# tests/test_strata.py
import unittest
class BuggyHelperTests(unittest.TestCase):
def test_worst_line_of_nothing(self):
from strata.complexity import _worst_line
self.assertEqual(_worst_line([]), 0)Fourteen seconds, and the docstring, the imports, the package __init__, the
other thirteen modules and the other thirty-nine tests are all gone — each one
individually proven irrelevant by deleting it and watching the bug survive.
Same tool, a C project built through a Makefile, failing with a segfault that prints no message at all:
$ carve ~/code/cdemo -- make run
files 17 → 4 76% gone
lines 122 → 16 87% gone/* src/main.c */ /* src/widget.c */
#include <stdlib.h> #include "widget.h"
int main(void) { int widget_width(struct widget *w) {
struct widget *w = NULL; return w->width;
int n = widget_width(w); }
}
/* include/widget.h */
struct widget { int width; };Six unrelated modules, both unused struct fields, widget_area, the printfs
and the return 0 are gone. carve has no idea what C is — it deleted text and
ran make.
pip install carve-cliNo dependencies, nothing to configure, Python 3.9+.
Every tool in this space answers a different question:
| Tool | Answers |
|---|---|
git bisect |
which commit introduced it |
| coverage | which lines ran |
| a profiler | which lines were slow |
| C-Reduce | how small one C file can get, if you have clang |
| carve | which files and which lines are load-bearing for this failure |
Coverage tells you what executed, and most of what executes is irrelevant. carve tells you what matters, by the only test that settles it: deleting something and seeing whether the bug survives.
It works on Python, Go, Rust, TypeScript, YAML, Terraform, Dockerfiles and anything else, because it never parses your code. It deletes text and runs your command.
Filing a bug report someone will act on. carve-out/REPRO.md is a single
markdown file with the command, the failure, and every remaining file inlined
in a fenced block. Paste it into an issue.
Pasting into an LLM. A 400-file repository does not fit in a context window. Fourteen load-bearing lines do, and they contain the whole bug.
Understanding your own code. When carve reports that a 2,000-line service class reduces to four lines and a config key, you have learned something real about that service class.
Isolating a flake. carve --allow-flaky --verify 5 keeps only the runs
that reproduce, and throws away the tree that never mattered.
This is what makes reduction trustworthy, and it is what most reducers get wrong. A reducer that only asks did it fail? will happily hand you a tree that fails because the reducer broke the syntax.
carve runs your command once, fingerprints the failure, and rejects any candidate that does not match it:
Signatures are normalised before matching, so shifting line numbers, temporary paths, memory addresses and durations never count as a different bug. carve also deliberately refuses to latch onto:
- tallies —
1 failed, 1 passed, because pinning to one stops carve deleting unrelated passing tests - echoed source — the
> assert x == yline, because pinning to it freezes the very line you want deleted - generic preambles —
Traceback (most recent call last):says that a failure happened, never which one
If the guess is wrong, say so yourself:
carve --expect 'IndexError: list index' -- ./run.sh # regex over the output
carve --signature 'Segmentation fault' -- ./run.sh # literal line
carve --expect-exit 139 -- ./run.sh # exit status- Copy. The tree is cloned into scratch directories, one per parallel job.
.gitand stale caches are left behind. - Fingerprint. One run establishes the failure; a second confirms it is
not a flake. carve refuses to reduce an intermittent failure unless you pass
--allow-flaky, because reducing against a coin flip produces nonsense. - Cut files. Delta debugging (
ddmin) over the file set, so whole directories can disappear in a single probe. - Cut blocks. Inside each surviving file, indentation-delimited and brace-delimited blocks are deleted wholesale — several disjoint ones per round, in parallel.
- Cut lines.
ddminagain, down to 1-minimal: removing any single remaining line stops the failure. - Unwrap. Deletion alone cannot get past
if debug:when the statement underneath it is the one that matters. Removing the header and dedenting its body can, and needs no grammar — it is true of every language that indents. - Cut tokens, at
--level chars: the same treatment applied inside each surviving line. - Repeat until a full round changes nothing, then verify the result.
Every probe is content-addressed and cached, so no candidate is ever run twice. docs/DESIGN.md goes through the whole thing, including why the oracle is the part that decides whether any of it can be trusted.
carve [DIR] -- COMMAND...
carve check [DIR] -- COMMAND... just show the failure carve would lock onto
--level files|blocks|lines|chars how deep to cut (default: lines)
-j, --jobs N candidates tested in parallel (default: 4)
--time-budget 10m stop after this long, keep the best result
--max-runs 500 stop after this many probes
--shrink-command drop command arguments that change nothing
--keep 'conftest.py' never delete or edit these
--only 'src/**' only reduce these
--link node_modules symlink instead of copying heavy directories
--stdout print REPRO.md instead of writing a tree
--level files is the fast one: it answers "which files matter?" in a fraction
of the probes, which is often all you needed.
--level chars is the thorough one. It keeps cutting inside each surviving
line, which is the difference between a repro you can read and one you can
publish:
# --level lines (14 probes)
def render(name, width, height, timeout=30, retries=5, verbose=False):
box = {"name": name, "w": width, "h": height}
return box["missing_key"]
render("panel", 80, 24, timeout=15, retries=2, verbose=True)
# --level chars (652 probes)
def render(name,height,verbose):
box={"":name,"":height}
return box["missing_key"]
render("",2,True)Every argument that survived is one whose removal stops the bug. Budget it with
--time-budget; carve keeps the best result it reached.
--shrink-command turns the reducer on your own invocation. The same oracle
applies, so an argument only goes if the failure is bit-for-bit unmoved:
$ carve --shrink-command -- pytest -vv --tb=long -p no:cacheprovider tests -q
command $ pytest -vv -qInterrupting with Ctrl-C is safe. carve always holds a state that reproduces, and reports the best one it reached.
- The directory you point carve at is opened read-only. Every candidate is tested in a scratch copy under your temp directory, removed on exit.
- Scratch trees are reused for speed but never polluted: anything a run creates — a cache, a build artefact, a marker file — is deleted before the next candidate, so no verdict is ever contaminated by the one before it.
- carve runs the command you gave it, many times over. That command should be one you are happy to run in a loop — a test suite, a build, a script. carve is a reducer, not a sandbox.
carve-outis never overwritten without--force, and carve refuses to write into the source tree.
MIT