skill
bob
bob: quality gates (CRAP, DRY, mutação, dependências) + skills para agentes de codificação. CLI bob, PyPI bob-quality.
Set up
Skills are installed by placing their folder where your agent loads skills.
The download comes from github.com/fazedordecodigo/bob itself, not from our server, so what you get is exactly what its author published.
Install instructions come from the README
README
synced Oct 25, 2026bob
English | Português
bob gives coding agents (Cursor, Claude Code, GitHub Copilot, Devin) deterministic quality gates for the code they change, plus skills that tell the agent when to run them. The gates measure CRAP, duplication, mutation, and dependencies, only for the functions in the diff. Each run exits with a code the agent can act on and writes a one-page report that tells the human reviewer where to look first. The gates follow Uncle Bob's workflow (see Credits).
- CLI:
bob(bob check | crap | dry | deps | mutate | report | waive | doctor | setup | update) - PyPI package:
bob-quality(sdist and wheel ready, not published yet) - Marketplace name:
bob(not published yet) - Configuration:
bob.yaml - Reports:
.bob/report.mdand.bob/report.json(schema v1) - Output language: English (
en, the default) or Brazilian Portuguese (pt-BR) - Docs: docs/README.md (guides in Portuguese; the spec stays in docs/SPEC.md)
License: MIT.
Status
These parts work on main:
- CRAP for Python and TypeScript.
- Duplication with jscpd, a warning by default and a failure above a ceiling in fail mode.
- The dependency gate, with the rule that forbids cycles.
- Differential mutation testing for Python and incremental Stryker mutation for TypeScript, and
bob waive. - The GitHub Action that comments the report on the pull request.
bob doctor, and thebob-qualitypackage ready to distribute. Nothing has been sent to PyPI.bob report, which renders a savedreport.jsonto Markdown.- Plugin manifests for Claude Code, Cursor, Copilot, and Devin, and the skill installer (#10). Nothing has been sent to a marketplace.
These parts are planned:
- Publication on PyPI and on the agent marketplaces.
Flow
The agent changes the code. The pre-push hook runs the fast gates on the developer's machine, and the GitHub Action runs all four gates on the pull request. Both measure only the functions in the diff, write the same report, and exit with the same codes.
flowchart TD
agent["Agent changes the code"]
agent --> hook["pre-push hook<br/>bob check --gates crap,dry,deps"]
agent --> pr["Pull request"]
pr --> action["GitHub Action<br/>bob check --gates crap,dry,deps,mutate"]
hook --> diff["Gates measure only the functions in the diff"]
action --> diff
diff --> report[".bob/report.md<br/>.bob/report.json"]
report --> code{"Exit code"}
code -->|"0 passed"| done["Push or merge check passes"]
code -->|"1 gate failed"| agent
code -->|"2 baseline tests failed"| agent
code -->|"3 survivor without waiver"| choice{"Kill or waive?"}
code -->|"4 config or tool"| doctor["bob doctor"]
choice -->|"stronger test"| agent
choice -->|"bob waive"| waivers[".bob/waivers.yaml"]
waivers -->|"committed with the code"| agent
report --> comment["Action creates or updates one PR comment"]
comment --> human["Human reviews the report and the waivers"]
Install
Run this at the root of your project (a git repository):
curl -fsSL https://github.com/fazedordecodigo/bob/releases/latest/download/install.sh | sh
For messages in Brazilian Portuguese, add -s -- --lang pt-BR after sh.
The command installs bob in BOB_HOME (by default ~/.local/share/bob-quality), links bob in ~/.local/bin, and adds that folder to the PATH in your shell profile. Then it runs bob setup in the project, which installs the pinned npm tools, gitleaks 8.30.1, the skills in .agents/skills, bob.yaml, and the pre-push hook. It uses no git clone, no plugin, and no sudo.
Requirements: Python 3.10 or newer, Node >=22.18.0 <23 with npm, git, curl, and sha256sum or shasum. Linux and macOS, on x64 and arm64.
Alternative: ask your AI agent
This path does not use install.sh. Paste the AI agent prompt into your agent at the project root; it installs step by step and reaches the same result. If the two paths differ, the one-line install wins.
Update
bob update
bob --version
bob update reads the latest release. When it is newer than your bob, it downloads that release's install.sh, checks its sha256, and runs it in the project. When only the project is behind, it runs bob setup. bob --version shows the bob version and the version of the project skills.
Installation (development)
You need Python 3.10 or later and Node 22.18.0, the versions the CI uses. The system Python of a current Ubuntu refuses pip install under PEP 668, so install inside a venv. To get Node 22.18.0, run fnm install 22.18.0 or nvm install 22.18.0, or download the .tar.gz from https://nodejs.org/dist/v22.18.0/. Choose the .tar.gz over the .tar.xz, because the image may not have xz.
python3 -m venv .venv && . .venv/bin/activate
python -m pip install -e ".[dev]"
npm ci
These commands install the bob command, the Python tools pinned in pyproject.toml (lizard, coverage, diff-cover, mutmut, import-linter, grimp), and the npm tools pinned in package.json (c8, vitest, jscpd, dependency-cruiser). bob doctor shows the expected and the found version of each tool.
Installing in the agents
The one-line install (see Install) runs bob setup, which copies the three skills listed in Skills to one folder of the project, .agents/skills. There is no plugin to install. Cursor, GitHub Copilot, and Devin read that folder. The Claude Code docs name only .claude/skills for project skills, so Claude Code may not see them; this risk is accepted (RA1). Skills from other sources in the folder stay as they are. Details in docs/getting-started/agents.md.
Usage
In a Python repository with bob.yaml and pytest:
bob check --base main
bob crap --base main
bob dry --base main
bob deps --base main
bob mutate --base main
bob waive <file::function|Target> --reason "texto de uma linha"
bob doctor
Each command accepts these flags:
| Command | Flags |
|---|---|
bob check | --base, --config, --out (default .bob/), --gates (default crap,dry,deps), --format (md, json, or both, default both), --dry-fail, --max-new-clones |
bob crap | --base, --config, --out, --threshold, --coverage-file |
bob dry | --base, --config, --out, --dry-fail, --max-new-clones |
bob deps | --base, --config, --out |
bob mutate | --base, --config, --out, --max-workers, --since-last-run (reuses the cache, which is already the default) |
bob waive | file::function or a Target from the mutation report, --reason |
bob doctor | --install |
Every command also accepts --lang en|pt-BR, before or after the command name. See Output language.
The commands write .bob/report.md and .bob/report.json. The exit codes are:
| Code | Meaning |
|---|---|
0 | Every gate that ran passed. |
1 | A gate failed. A changed function has CRAP at or above the limit, the diff has a dependency violation, or, in DRY fail mode, the new clones exceed duplication.max_new_clones. |
2 | The baseline tests failed before bob could measure. The crap gate runs them in the default bob check too. |
3 | A surviving mutant has no waiver. |
4 | Missing or different tool, or invalid configuration. The message goes to stderr in English, and the run writes nothing. |
Invalid command-line usage exits 4, writes no report, and the stderr does not mention baseline. Help (-h, --help) stays 0.
--config before the command exits 4 and writes no report. The last stderr line stays English even with --lang pt-BR. It names the command with the flag after the name, as in bob: error: --config goes after the command: bob check --config ci.yaml. A path with a space is quoted, as in bob crap --config 'my ci.yaml'. doctor does not accept --config. Its last line is bob: error: doctor does not accept --config. waive does not accept it either. Its last line is bob: error: waive does not accept --config. bob --help does not list --config.
When more than one gate fails, bob check exits with the highest code.
A gate with nothing to measure does not raise the exit code. With no code under paths.include (outside paths.exclude), crap and coverage show ⏭️ not run (no code to measure), and the run can exit 0.
A local bob check does not run mutation. bob check --gates mutate and bob mutate run it, only on the changed functions and on the functions that still have survivors.
How it works
Only the diff blocks
bob runs git diff <base>...HEAD and maps the changed lines to functions. A function counts as changed when the diff touches one of its lines. Only changed functions, dependency violations that touch changed files, and, in DRY fail mode, clones that touch changed lines can fail a gate. Old debt goes to the informational sections of the report, marked outside the diff; does not block, and does not change the exit code. --base overrides the base key in bob.yaml.
Pinned tools per language
bob wraps each tool in its own adapter and copies no code from them. The versions are pinned in pyproject.toml and package.json.
| Gate | Python | TypeScript |
|---|---|---|
| CRAP | lizard 1.24.1 for complexity, coverage 7.16.2 running pytest | lizard 1.24.1 for complexity, c8 12.0.0 running vitest 5.0.5 |
| Duplication | jscpd 5.4.1 | jscpd 5.4.1 |
| Mutation | mutmut 3.8.0 | @stryker-mutator/core 10.0.0 and @stryker-mutator/vitest-runner 10.0.0 |
| Dependencies | import-linter 2.15, grimp 3.17 | dependency-cruiser 18.6.0 |
| Diff coverage | diff-cover 10.6.0 on coverage.xml | diff-cover 10.6.0 on c8 lcov |
The npm tools need Node, and jscpd needs it in a Python-only repository too. In a repository with Python and TypeScript, a missing Node is not exit 4. bob still measures Python and reports TypeScript as skipped, with a warning. Exit 4 stays when the missing tool blocks the requested measurement, as in a TypeScript-only repository without Node.
bob.yaml
bob reads these keys. The values below apply when a key is omitted. languages is detected from the tree when bob.yaml is missing or the key is omitted, and [python] remains only when no matching source file exists:
base: origin/main
language: en
languages: [python]
paths:
include: [src]
exclude: [tests, node_modules, dist]
crap:
max_per_function: 10
scope: changed
duplication:
min_tokens: 50
new_clones: warn
max_new_clones: 0
min_similarity: 0
min_lines: 0
coverage:
min_percent: 80
dependencies:
python: .importlinter
typescript: .dependency-cruiser.cjs
With crap.scope: changed, only changed functions block. all counts every measured function. The value is changed or all. Omitting languages, or setting it to null, detects the list from files under paths.include and outside paths.exclude. The scan does not follow symlinks. paths.exclude matches a path prefix or a path component, not a glob. .py selects python. .ts, .tsx, .js, .jsx, .mjs, and .cjs select typescript, in that order. If none of those files exist, the result is [python]. The same detection runs when bob.yaml is missing and --config was not passed. An explicit list is kept as written. An empty list exits with 4.
If the YAML is not a mapping, the YAML has a syntax error, a mapping repeats a key, or a key has the wrong type, the run exits with 4. The message goes to stderr in English and names the file. A wrong type also names the key, as in bob.yaml: crap must be a mapping or bob.yaml: crap.max_per_function must be a number. A repeated key names the key and the line, as in invalid YAML in bob.yaml: found duplicate key 'base' on line 2. check, doctor, waive, and the other commands do this the same way. None of them writes a file.
If --config points to a file that does not exist, check, crap, dry, deps, and mutate exit with 4. English stderr names the path load_config received (config file not found: <path>). The run writes nothing. doctor and waive do not accept --config.
Output language
bob writes its text for humans in English (en) or Brazilian Portuguese (pt-BR). That covers the CLI messages, the --help text, bob doctor, the pre-push hook, and .bob/report.md. The first source that is set wins:
--lang en|pt-BR, before or after the command name.- The
BOB_LANGenvironment variable. languageinbob.yaml.en.
The case matters (pt-br is invalid). An empty --lang or BOB_LANG is invalid. The run exits with 4, prints invalid language: "" (use en or pt-BR) to stderr, and writes nothing. An empty language key in bob.yaml still counts as unset.
An invalid --lang or BOB_LANG, a bob.yaml that is not a mapping, YAML with a syntax error, or a key with the wrong type exits with 4. The message goes to stderr in English, even when --lang or language is pt-BR. The run writes no report and no waivers.yaml. check, doctor, waive, and the other commands do this the same way. An invalid duplication, an unknown key, a numeric language, a language other than en or pt-BR, a crap.scope other than changed or all, a languages member other than python or typescript, a crap.max_per_function that is not a finite number greater than 0 (an integer too large to convert to float counts as not finite), an empty languages list, a repeated languages item, duplication.min_tokens of 0 or less, a duplication.new_clones other than warn or fail, a negative duplication.max_new_clones or duplication.min_lines, a duplication.min_similarity that is not a finite number from 0 to 100, waivers set to 0, false, or "", and an empty reason also exit with 4 on check, doctor, and waive. The English stderr names the path and the field, and the run writes nothing.
.bob/report.json is the same bytes in both languages. Every gate reason, the cycle rule name, and reproduce stay English, so a tool can match them without knowing the output language. reproduce in the JSON omits --lang. English calls the cycle rule Forbidden dependency cycle. pt-BR calls it Proibido ciclo de dependência in report.md and in the pull request comment. A dependency-cruiser rule with no name is dependency in the JSON and dependência in the Markdown. The reproduce line in report.md includes the --lang that produced it.
In CI, set language: pt-BR in bob.yaml, or BOB_LANG: pt-BR in the job env. The GitHub Action input language sets the same choice for bob check and for the pull request comment. An empty input leaves BOB_LANG and bob.yaml in charge. The comment's truncation note and its own errors use that language.
bob doctor and --install
bob doctor lists each tool with the pinned and the found version. It exits with 0 when all match, and with 4 when a tool is missing or differs, together with the install command. Python and npm tools must match exactly. Node must be >=22.18.0 <23, so Node 20, Node 24, or 22.18.0-rc.1 gives 4. If the package directory is not writable, the message asks for bob doctor --install.
bob doctor --install runs npm ci in the installed package. When npm fails there with EACCES, EPERM or EROFS, it installs into a user cache ($XDG_CACHE_HOME/bob-quality, or ~/.cache/bob-quality). Other npm errors exit with 4 and the exact command. Then it prints the same report.
Waivers
Waivers live in .bob/waivers.yaml, which you commit with the code. bob waive writes one entry with these fields:
id,file::functionfor every survivor of that function, orfile::function::<description>for one survivor.reason, one line of text.author, fromgit config user.name, or$USER.date, the UTC date.
A waiver without a one-line reason is rejected with exit 4. bob waive of an id that already appears once replaces that row and exits 0. A file that already contains two rows with one id exits 4 and the bytes stay. The description is the mutant's operator with the outer spaces stripped. For Python it is the mutmut show diff. For TypeScript it is {mutatorName}@{line}:{column}-{line}:{column}, plus -> {replacement} when Stryker reports one. The exact form exists only when no other survivor of the function has the same description. The Target column of the survivor table shows it, or — when it does not exist. bob waive accepts only a target of a survivor in the last mutation run, read from .bob/mutation-cache.json and .bob/stryker-mutants.json. Any other target, such as the mutant id or a bare function name, exits 4 and writes nothing. A hand-edited id that matches no survivor leaves the survivor unwaived. In TypeScript, a line inserted above the mutant changes its description, so the old waiver stops matching and the gate exits 3. See ADR 0006. A waivers.yaml that is valid YAML with the wrong shape exits with 4. The message is English on stderr and names .bob/waivers.yaml and the field, as in .bob/waivers.yaml: waivers[0].reason must be a string. A top-level list, a waivers value that is not a list, an item that is not a mapping, and a missing field all do this. The run writes no report.md and no report.json, and the file stays as it is. check, doctor, and waive do this the same way, even when --lang is pt-BR. A YAML syntax error also exits with 4. That message names .bob/waivers.yaml and the parser error. The agent writes the waiver, and the human approves it in the pull request review. Every waiver appears at the top of the report.
The report
.bob/report.md is one page for the reviewer. .bob/report.json has the same data and follows the schema in src/bob_quality/schemas/report_v1.json (schema_version: 1). --format picks one file or both. The Markdown report has these parts, with the headings in the output language. The names below are the en ones, and README.pt-BR.md lists the pt-BR ones:
Waivers, when there are waivers or mutation ran.- A header with the commit, the base, the date, and the tool versions.
Verdict, one line per gate: ✅ passed, ⚠️ warning, ❌ failed, or ⏭️ not run with the reason. A gate that did not run never shows as ✅.Where to focus the review, the changed files ordered by risk (highest CRAP, plus clones and survivors).Top 5 functions by CRAP (diff).Information (old debt), functions outside the diff at or above the limit.Dependency violations and cycles, andInformation (dependencies outside the diff).New clones.Surviving mutants.Churn, the commit counts and the relative rate (changed lines divided by the file size) for the changed files inside the include paths. The heading is always there, and the section does not change the exit code.How to reproduce, the exact command to run locally, including--lang. The same command inreport.jsonomits--lang.
Churn counts the commits git log lists for each changed file inside paths.include, over churn.window_days calendar days (default 90) ending on that commit's own date. Relative churn for a file is its changed lines divided by its line count. The rate is null when the file has no lines or cannot be read, and the commit count stays. A changed function's hotspot is the commit count times its cyclomatic complexity. Diffusion is how many of those files changed, and how many parent directories they sit in. When the count cannot be read, the heading stays and the numbers do not. The JSON field is churn, and schema_version stays 1.
Concepts
CRAP
CRAP combines the complexity of a function with how much of it the tests run. A complex function with few tests is where a change is most likely to break something nobody sees, so the reviewer looks there first.
CRAP = CC² × (1 − cov)³ + CC
CC is the cyclomatic complexity from lizard. cov is the fraction of the function's executable lines that the tests ran, from coverage.py or c8. A function with no executable lines counts as fully covered.
| CC | Coverage | CRAP | Result |
|---|---|---|---|
| 4 | 50% | 16 × 0.125 + 4 = 6 | passes |
| 5 | 0% | 25 × 1 + 5 = 30 | fails |
| 10 | 100% | 0 + 10 = 10 | fails |
The limit is CRAP < 10 per changed function. A function with CRAP of 10 or more fails the gate with exit 1. The gate runs the tests to measure coverage, so a broken baseline gives exit 2, in the default bob check too. Because CRAP is never below CC, a changed function with CC 10 or more fails at the default limit even with full coverage. Change the limit with crap.max_per_function in bob.yaml or with bob crap --threshold.
Duplication (DRY)
A clone is a block of code that repeats another block. New clones show where the agent copied code instead of reusing it.
bob runs jscpd and keeps only the clones where at least one side touches a changed line. Two filters then choose which new clones count, in both modes. A clone counts when it has at least duplication.min_lines lines (default 0) and at least duplication.min_similarity percent similarity (default 0). A clone without a percentage, such as an exact one, counts as 100. jscpd already drops clones under 5 lines, its own default, so a min_lines below 5 does not bring them back. A clone that does not count stays out of the report.
duplication.new_clones picks the mode. In warn mode, the default, the exit code stays 0. For example, if the diff adds a block of at least 50 tokens that matches existing code, the report lists the pair under New clones and the verdict shows ⚠️. In fail mode, the gate shows ❌ and exits with 1 when the count is above duplication.max_new_clones (default 0), so one counted clone already fails. The reason names the key and the ceiling, as in 1 new clone exceeds duplication.max_new_clones 0. When the count is at or under the ceiling, fail mode shows ⚠️.
bob dry and bob check accept --dry-fail, which turns on fail mode for that run, and --max-new-clones N, which replaces the ceiling for that run. 0 is a real ceiling. No flag forces warn mode. A negative --max-new-clones exits with 4 before any report. The pre-push hook passes neither flag, so it follows bob.yaml.
duplication.min_tokens (default 50) is the jscpd token minimum. Without Node, the gate shows as ⏭️ and the exit code stays 0, in fail mode too.
Mutation testing
Coverage shows that a line ran. It does not show that a test checks the result. Mutation testing makes a small change to the code, called a mutant, and runs the tests again. If a test fails, the mutant is killed. If every test still passes, the mutant survived, and that line is not really tested.
killed,survived,timeout, andno_coverageare the mutant states.no_coveragemeans no test ran the mutant.- An equivalent mutant changes the code without changing its behavior, so no test can kill it. bob has no separate state for it. Record a waiver instead, as the
hardenskill describes. - A waiver is a one-line reason, approved by the human in review, for a survivor that should not be killed.
For example, a mutant changes if total > limit: to if total >= limit:. If no test uses total == limit, the mutant survives. Write that test, or run bob waive with a reason.
bob mutates Python with mutmut and TypeScript with Stryker. It runs the baseline tests first and exits with 2 if they fail. It mutates the changed functions, plus the functions that still have survivors without a waiver. The cache .bob/mutation-cache.json stores a hash of each function's text. Reuse also requires the mutmut version and the suite fingerprint to match. A mismatch still remutates an unwaived survivor outside the diff. The GitHub Action restores and publishes the cache as the bob-mutation-cache artifact. TypeScript keeps Stryker's incremental file at .bob/stryker-incremental.json. The Action restores and publishes that file in the same artifact. mutmut runs in .bob/mutmut/ and never writes the repository's setup.cfg or mutants/.
The limit is zero unwaived survivors on a changed line, and zero no_coverage mutants on a changed function. Either one fails the gate with exit 3. An unwaived survivor on a line the diff did not change stays in the report and does not change the exit. The report lists survivors, and it lists no_coverage mutants by function and file. killed and timeout do not block. A waiver does not cover no_coverage.
Dependencies
Dependency rules and cycles show architecture erosion. When module A imports B and B imports A, neither can change alone.
bob runs the rules the repository already declares, in .importlinter for Python (import-linter) and in .dependency-cruiser.cjs for TypeScript (dependency-cruiser). It adds one rule that is always on and forbids dependency cycles. The English name is Forbidden dependency cycle. The pt-BR name is Proibido ciclo de dependência. For example, if app.a imports app.b and the diff makes app.b import app.a, the gate shows ❌ and exits with 1.
The limit is zero violations in the diff. A violation blocks only when its origin, its destination, or another module in the same cycle (strongly connected component) changed in the diff. Older violations appear as outside the diff; does not block.
Pre-push hook
The script src/bob_quality/hooks/pre-push runs bob check --gates crap,dry,deps, the fast gates. It follows BOB_LANG and language in bob.yaml. tests/test_hook.py checks that it finishes in under 60 s on the fixture.
The one-line install writes it (see Install). If the project already has a pre-push hook that bob did not write, bob setup keeps it and prints the line to add to it.
Tests
pytest
The repository fixtures are in tests/fixtures: python_app (a passing case and a case that fails with CRAP ≥ 10), ts_app, and mixed_app. tests/fixtures/raw has recorded output from the pinned tools.
The CI (.github/workflows/ci.yml) runs this suite on Python 3.10 and 3.12 with Node 22.18.0, on pull requests and on pushes to main.
GitHub Action
The composite action in .github/actions/bob installs bob and runs bob check with crap,dry,deps,mutate. It installs Node 22.18.0 and jscpd 5.4.1 first, so the dry gate runs when the caller did not install them. It restores and publishes the bob-mutation-cache artifact (.bob/mutation-cache.json and .bob/stryker-incremental.json, kept for 7 days), and it creates or updates a single pull request comment with .bob/report.md. The job exit code is the one from bob, including 4 for an invalid command line.
The action accepts the inputs base, gates, pip-spec, and token. This repository uses it in .github/workflows/bob.yml. bob-quality is not on PyPI yet, so in a checkout of this repository the action runs pip install -e ".[dev]", and in another repository it installs the action's git source. Publication on the GitHub Marketplace is not part of this slice.
Publishing
.github/workflows/publish-plugins.yml validates the Cursor plugin on a v* tag, on a published release, and on workflow_dispatch. The job does not submit the plugin. The manual form, the version check, and the lack of secrets are in docs/publishing.md.
Skills
skills/bob-check/says when and how to runbob check.skills/harden/says how to kill a surviving mutant or record a waiver.skills/review-report/summarizes.bob/report.mdin the pull request.AGENTS.mdis the index for agents..claude-plugin/and.cursor-plugin/are the plugin manifests. See Installing in the agents.
Credits
The gates follow Uncle Bob's workflow. The READMEs and the output of his tools crapper and mutator (0BSD) are the behavior reference, and bob reuses none of their code. The exit codes take after mutator. The duplication gate uses jscpd. A structural dryer of bob's own, in the line of his dryer (0BSD), is out of scope for the MVP. The limits come from his recent posts, CRAP < 10 for agents (post on X) and kill mutants within reason (Aviator podcast).
The market tools (lizard, coverage.py, diff-cover, c8, vitest, mutmut, jscpd, import-linter, grimp, dependency-cruiser) are pinned dependencies, and bob copies no code from them. grimp has no adapter.
License
MIT. See LICENSE.