Skip to main content
Glama
adrianov

abcop_inspection

by adrianov
README.md
# abcop

**A must-have gate for AI-written code.** Agents ship faster than humans
can re-read; abcop keeps that code *understandable* by gating function and
module ABC complexity so every unit fits a human head and an LLM context
window. Diagnostics are also a **hook for automated refactoring** —
extract a method, split a module — so CI rejects bad growth *and* points
agents at concrete maintainability fixes.

One self-contained binary across Ruby, Rust, Python, Go, PHP, Java, C#,
Dart, JavaScript, TypeScript, C, C++, Objective-C, Swift, Solidity, Zig
and Haskell —
no runtimes, no plugins, no per-language installs. Written in Rust for
speed: one parse per file, one walk per metric, grammars compiled in;
whole trees in milliseconds.

## Install

```sh
brew install adrianov/abcop/abcop
```

```sh
cargo install abcop
```

macOS (`.tar.gz` from [GitHub Releases](https://github.com/adrianov/abcop/releases); binary + man):

```sh
# Apple Silicon — use the *-x86_64-apple-darwin.tar.gz asset on Intel Macs
curl -LO https://github.com/adrianov/abcop/releases/download/v0.22.0/abcop-0.22.0-aarch64-apple-darwin.tar.gz
tar -xzf abcop-0.22.0-aarch64-apple-darwin.tar.gz
sudo cp abcop-0.22.0-aarch64-apple-darwin/abcop /usr/local/bin/
sudo mkdir -p /usr/local/share/man/man1
sudo cp abcop-0.22.0-aarch64-apple-darwin/abcop.1 /usr/local/share/man/man1/
```

Ubuntu / Debian (`.deb` from [GitHub Releases](https://github.com/adrianov/abcop/releases); amd64, Ubuntu 22.04+ / Debian bookworm+):

```sh
# example for v0.22.0 — use the asset name from the release page
curl -LO https://github.com/adrianov/abcop/releases/download/v0.22.0/abcop_0.22.0-1_amd64.deb
sudo dpkg -i abcop_0.22.0-1_amd64.deb
man abcop
```

## Example run

```text
lib/sinatra/base.rb:1254:0: C: Metrics/AbcSize: Assignment Branch Condition size for `error_block!` is too high. [<7, 14, 9> 18.06/17]
src/main.rs: W: Metrics/ModuleAbcSize: Assignment Branch Condition size for module is too high. [<80, 200, 60> 228.04/120] -- extract a coherent subunit
132 files analysed in 0.09s, 7 abc offenses, 0 used-once offenses, 0 never-used warnings, 14 module-abc warnings
```

## Why ABC, not line counts

Line counts lie. The [ABC metric](https://en.wikipedia.org/wiki/ABC_Software_Metric)
(Jerry Fitzpatrick, *C++ Report*, June 1997) counts what does work —
assignments (A), branches (B), conditions (C) — as `sqrt(A² + B² + C²)`.
Fitzpatrick defined both method and module scope; abcop gates both:

| Rule | Severity | Meaning |
|---|---|---|
| `Metrics/AbcSize` | C | function ABC above `--max-abc` (default 17). A normal scan reports a rise vs `HEAD`; `--full` and `--everything` report every function over the ceiling |
| `Metrics/ModuleAbcSize` | W | module ABC above `--max-module-abc` (default 120). A normal scan reports a rise vs `HEAD`; `--full` and `--everything` report every production module over the ceiling |
| `UsedOnce` | W | local written once and read once, inside a function the edit changed. `--full` and `--everything` report every such local |
| `NeverUsed` | W | local written and never read, inside a function the edit changed. Same whole-tree switch as UsedOnce |

A sparse wrapper and a dense god-object can share a line count; ABC
separates them. Complexity is the gate — never a line budget. UsedOnce /
NeverUsed are secondary.

## Built for AI workflows

- **Understandable by construction.** AbcSize 17 and ModuleAbcSize 120 keep
  every new unit inside a human head and an LLM context window. An existing
  unit may stay over that ceiling when the edit does not raise its score.
  Models edit small units reliably; when something breaks, the blast radius
  is tiny.
- **Refactoring hook.** Exit code `1` plus stable JSON/JSONL diagnostics
  (`rule`, `score`, `vector`, `message`) feed agents and scripts: split
  oversized modules, extract hot methods. Each finding is an actionable
  maintainability step, not a style nit.
- **Fast enough for every push.** Sub-second whole-tree scans — including
  code an agent will never re-read tomorrow.
- **Signal only.** No formatting or style cops. Vendored, generated and
  test trees are skipped by default. Knobs: `--max-abc` and
  `--max-module-abc`. `--full` and `--everything` report every hit, not
  only the current edit.
- **Prefer MCP with LLMs.** `abcop --mcp` is the best way to use abcop from
  an agent: the model gets offenses as soon as it writes, so it can fix
  complexity and dead locals in the same turn and ship effective code
  right away — not after a later CLI/CI pass. Official Rust
  [`rmcp`](https://crates.io/crates/rmcp) SDK; tool `abcop_inspection`.
  Listed on the
  [MCP Registry](https://registry.modelcontextprotocol.io/) as
  `io.github.adrianov/abcop` (crates.io package + each GitHub `v*` tag).

Inspired by [RuboCop](https://github.com/rubocop/rubocop) (Ruby AbcSize
parity and `# rubocop:disable` directives) and
[lizard](https://github.com/terryyin/lizard) (one tool, many languages).
Ruby method counting matches RuboCop 1.89 byte-for-byte
(`scripts/compare_parity.py`). A block outside a method (`match do`,
`before do`, a class-level `each`) is its own unit; `each` / `map` /
`select` still roll into the block that contains them.

## Advantages

- **~850k–940k LOC/s** per core (Apple M1 Pro). RuboCop's lib tree
  (943 files, 110k LOC): **0.13 s** — ~39× faster than
  `rubocop --only Metrics/AbcSize` (cache off), ~140× a full rubocop run.
- **One binary, zero deps** — grammars and an embedded result cache
  (`cache.redb`) compiled in; works on clean CI images with no language
  toolchains.
- **Deterministic** — same findings every run; text / JSON / JSONL stream
  as files finish; `--sort-by-score` buffers for worst-first emit.

## Usage

```sh
abcop [OPTIONS] PATH...
```

| Option | Default | Meaning |
|---|---|---|
| `[PATH]...` | auto | targets; omitted → [scope selection](#scope-selection) |
| `--max-abc N` | `17` | function ABC ceiling |
| `--max-module-abc N` | `120` | module ABC ceiling |
| `--only abc\|used-once\|never-used` | all | single check |
| `--full` | off | whole production tree, and every function over the ceiling plus every local (default skips stay on) |
| `--everything` | off | no gitignore / hidden / vendored pruning (`ignore` in the user config still applies); same full report as `--full` |
| `--format text\|json\|jsonl` | `text` | CI-friendly output |
| `--sort-by-score` | off | highest ABC first |
| `--mr` | off | MR scope (uncommitted + branch vs base) |
| `--uncommitted` | off | working-tree + index + untracked vs `HEAD` only |
| `--no-cache` | off | skip on-disk cache |
| `--mcp` | off | MCP server on stdio (for AI clients) |
| `--dump-tree FILE` | — | debug syntax tree |

Exit codes: `0` clean, `1` findings, `2` usage error.

```sh
abcop app lib                          # two trees; rises and edited functions only
abcop --format jsonl lib > abcop.jsonl # streaming CI / agent input
abcop --only used-once src             # inline candidates in edited functions
abcop --max-abc 12 --only abc lib      # stricter function budget
abcop --full lib                       # every function over the ceiling, every local
abcop --everything lib                 # same report, with no default pruning
abcop --max-module-abc 80 lib          # stricter module budget
abcop --sort-by-score --only abc lib   # worst first
abcop --uncommitted                    # pre-commit; rises and edited functions only
abcop --mr --only abc                  # branch files; still only ABC rises vs HEAD
abcop --mcp                            # MCP server on stdio (for AI clients)
```

JSON diagnostics include `file`, `line`, `column`, `severity`, `rule`,
`message`, plus `score` / `vector` for ABC rules:

```json
{"rule":"Metrics/AbcSize","score":10.0,"vector":"<6, 8, 0>"}
{"rule":"Metrics/ModuleAbcSize","score":120.5,"vector":"<40, 100, 40>"}
```

### Scope selection

**Named paths** — those targets only. Findings still follow the edit filter below.

**Omitted** — narrowest useful scope, announced on stderr:

1. uncommitted work vs `HEAD` if the tree is dirty
2. else current MR (`--mr` forces this)
3. else the tree (outside a repo)

Default walks prune test/fixture trees, vendored/build output
(`vendor/`, `node_modules/`, `target/`, …), `db/migrate/`, route tables
(`config/routes.rb`, `config/routes/*.rb`), and generated names
(`*.min.js`, `*_pb.go`, …). Name a path explicitly to scan it anyway.
Third-party, route-table, and fixture paths are never scoped review
surface — a diff through `vendor/` or `tests/fixtures/` does not make
that material owned code.

On a dirty tree the bare default is uncommitted-only; `--mr` takes the
full branch union. Commits straight to main use a 36-hour window when no
branch base applies. `--uncommitted` fails outside a repository instead
of silently widening.

### What a normal scan reports

Every scan except `--full` and `--everything` compares the working tree
to `HEAD`:

- **AbcSize** reports a method over `--max-abc` only when its score is
  strictly higher than the same method in `HEAD` (same name, source
  order). A score that stayed the same or fell stays quiet, including a
  legacy method that was already over the ceiling. A method that exists
  only in the working tree counts as an increase. The message asks to
  return to the previous score when that score was already over the
  ceiling (`rose from 20 to 21 -- back to 20 or below`). When `HEAD` was
  under the ceiling, the target is the ceiling (`rose from 16.28 to 21.28
  -- back to 17 or below`).
- **UsedOnce / NeverUsed** report only inside a function whose text
  changed against `HEAD`. A local in a function the edit did not touch
  stays quiet. Locals outside any function still report.
- **ModuleAbcSize** reports a module over `--max-module-abc` only when its
  whole-file score is strictly higher than that file in `HEAD`. A score
  that stayed the same or fell stays quiet, including a legacy module
  already over the ceiling. A file that does not exist in `HEAD` is held
  to the ceiling. The message asks to return to the previous score when
  that score was already over the ceiling (`rose from 200 to 210 -- back
  to 200 or below`). When `HEAD` was under the ceiling, the target is the
  ceiling.
- **No `HEAD` blob** (not a git checkout, or an untracked file) reports
  every method over the ceiling, every local, and every module over the
  ceiling.

`--uncommitted` and `--mr` also drop function and local findings on lines
the diff did not touch.

### Whole-tree report

`--full` and `--everything` report every method over `--max-abc`, every
local, and every production module over `--max-module-abc`. ModuleAbcSize
still exempts test trees on those runs. `--full` keeps the default skips.
`--everything` lifts gitignore, hidden directories, and vendored/generated/test
pruning. `ignore` in the user config still applies to both.

### User config

One file holds user options. It is `$XDG_CONFIG_HOME/abcop` when that
variable is set, otherwise `~/.config/abcop`. `$ABCOP_CONFIG` replaces
the path. Nothing is written into the project.

`ignore` is a gitignore pattern, one option per line, applied in every
project relative to that project's root. A directory pattern covers
everything inside it. Naming the path, `--full`, and `--everything` do
not bring a match back. An MCP inspection of a matched path is empty.

```text
ignore = app/services/legacy/
ignore = lib/old.rb
```

### Directives

Everywhere except Rust, RuboCop-style `#` / `//` suppressions work
(trailing and block; bare `Metrics` allowed). `rubocop:disable-next` is
ignored, matching rubocop.

```ruby
def legacy_path # rubocop:disable Metrics/AbcSize
  ...
end
```

## MCP (Model Context Protocol)

**Preferred for LLM / agent workflows.** Wire abcop as an MCP server so the
model can call `abcop_inspection` while it edits: feedback arrives in the
same turn, the agent corrects soon, and it writes maintainable code on the
first pass instead of discovering ABC / UsedOnce / NeverUsed only in CI.

`abcop --mcp` runs a long-lived MCP server on stdio — same idea as
[RuboCop’s MCP](https://docs.rubocop.org/rubocop/latest/usage/mcp.html)
and [rrubocop](https://github.com/adrianov/rrubocop), with no Ruby `mcp`
gem. Tool:

| Tool | Purpose |
|---|---|
| `abcop_inspection` | Analyse via `path` / `paths` (string or array) and/or inline `source_code`; returns compact offense JSON (`code`, `line`, `column`, `message`; `score` / `vector` for ABC rules). Same edit filter as a normal scan: AbcSize and ModuleAbcSize only when the score rose against `HEAD`, UsedOnce and NeverUsed only inside functions the edit changed. A new module is held to the module ceiling. Always pass an explicit project path — omitting targets errors out (avoids scanning `$HOME` when MCP `cwd` is mis-set). |

- MCP Registry name: `mcp-name: io.github.adrianov/abcop`

Metadata lives in [`server.json`](./server.json), which is part of the
Cargo package on [crates.io](https://crates.io/crates/abcop). Each GitHub
`v*` release tag publishes that listing to the
[MCP Registry](https://registry.modelcontextprotocol.io/) (after the
crates.io upload).

Example client config (Cursor / VS Code / Windsurf):

```json
{
  "mcpServers": {
    "abcop": {
      "type": "stdio",
      "command": "abcop",
      "args": ["--mcp"],
      "cwd": "/path/to/your/project"
    }
  }
}
```

Claude Code:

```sh
claude mcp add abcop -- abcop --mcp
```

Intended for MCP clients, not interactive use.

## Caching

Content-addressed cache under `$XDG_CACHE_HOME/abcop` (or `~/.cache/abcop`;
override with `ABCOP_CACHE_DIR`). Warm reruns ~5× faster. Keys cover
contents, version, rule revision, threshold, checks and path — no
cross-project collisions. Auto-pruned to 20 000 entries; `--no-cache`
disables. Nothing is written inside the project.

## Supported languages

Seventeen languages plus embedded JS in HTML/templates, four rules each, one CI gate.

| Language | Files | Notes |
|---|---|---|
| Ruby | `.rb .rake .ru .gemspec`, `Gemfile`, `Rakefile`, … | RuboCop-parity method AbcSize; blocks outside methods are units |
| Rust | `.rs` | |
| Python | `.py .pyi .pyw` | |
| Go | `.go` | |
| PHP | `.php` | |
| Java | `.java` | |
| C# | `.cs` | |
| Solidity | `.sol` | |
| Dart | `.dart` | |
| Zig | `.zig` | |
| Haskell | `.hs .lhs` | |
| JavaScript | `.js .mjs .cjs .jsx` | also `<script>` in HTML/templates |
| TypeScript | `.ts .tsx .mts .cts` | `lang="ts"` / typescript script types |
| Embedded JS | `.html .htm .xhtml .erb .slim .haml .pug .vue .svelte .ejs .njk .jinja .j2 .twig .liquid .hbs …`, `*.js.erb` | extract scripts; blank `<% %>`, `{{ }}`, `{% %}` holes |
| C / C++ | `.c .h .cc .cpp .cxx .hpp .hxx .hh` | `.h` via C++ grammar |
| Objective-C | `.m .mm` | |
| Swift | `.swift` | |

Named declarations are measured units; anonymous function-likes roll into
the enclosing unit; nested units never double-count. C-family: file-scope
globals are out of single-file reach; loop-head locals are protocol;
field writes also read the object; export-macro class forms
(`class UTIL_EXPORT Foo`) are skipped for variable rules.

## Benchmarks

Apple M1 Pro, warm cache:

| Corpus | Size | abcop | rubocop `--only AbcSize`¹ | Full rubocop |
|---|---|---|---|---|
| rubocop/lib | 943 files, 110k LOC | **0.13 s** | 5.1 s (~39×) | 18.3 s |
| cargo registry sample | 6,603 files, 2.6M LOC | **~2.8 s** | — | — |

¹ `--cache false` — rubocop's own cache otherwise makes repeats look free.

## Development

```sh
cargo test
cargo build --all-targets   # zero warnings
abcop src                   # dogfood
```

Shared engine: `src/scope_model/` (`backend`, `walk`, `eval`); language
collectors sit beside each backend (`clike/`, `sollang/`, …). Probe
grammar nodes with `abcop --dump-tree FILE`. Parity:
`scripts/compare_parity.py`.

## License

**GNU GPL v3 or later** (SPDX: `GPL-3.0-or-later`). See [LICENSE](LICENSE).

Copyright © 2026 Peter Adrianov. All rights reserved.