How Cribelo works

Cribelo reads the diff of a branch and gives back three things: the lines that still need a human, the facts the diff does not show, and the order to read the files in. No model runs behind it, so the same change gets the same answer on every machine.

git diff maincribelo

28 changed lines in four files. Git shows them all alike. 16 of them only moved, got renamed or reformatted. 12 are left. 5 sit in simple code that gained no decision: a glance. 7 need a close read. Two facts the diff does not show. Read token.go first: the two others depend on it. Skip format.go. Read 3 of 4 files. 7 of 28 lines to read closely, 5 to glance at.

From the diff to the plan

The change is the diff between the merge base of your branch and your working tree. Generated files, lock files and binaries are set aside. The rest is parsed before and after the change with the tree-sitter parsers of AST Metrics, then goes through three passes.

The diff merge base to your tree Hunks what is left to read Facts what the diff hides Order how to read it

Hunks

The first pass decides what is left to read. Every hunk of the diff gets one nature. The first rule that applies wins, and the nature sets how many lines you read.

NatureRuleYou read
movedHalf or more of its lines sit in a function that arrived from another place with the same body, identifiers aside.2 lines, plus every new line
extractedIts added lines are a function cut out of one that shrank.2 lines
renameIt differs from its old lines by one consistent old → new mapping of identifiers, seen in two hunks at least. The old name is gone from the file.1 line
formatSame tokens, whitespace aside.nothing
commentOnly comments differ.nothing
importsImport statements only.nothing
behaviorEverything else.every code line: blank, comment and import lines are not counted
set aside to read imports 3 lines, nothing to read moved from parser.go 12 lines: a glance at 2, and 1 line to read, the one that was not in the original function rename isDone → done 4 lines, 1 to read behavior 9 lines, 9 to read 28 changed lines, 13 to read
One file of a change. The mechanical hunks are set aside, the lines they could hide are kept.

A move is matched function by function: same tokens, same tokens once identifiers are normalized, or 80 % of common fingerprints, the method of RefDiff. Then it is carried line by line. A line of the moved hunk that was not in the original function, or that names something the original never named, is new, and you read it. A file whose every hunk is mechanical never enters the reading list. It is listed under Skip with its reason: moved code only, renames only, formatting only.

Read closely, or glance at

The code lines left are then split by what holds them. A line in a function that gained no decision and stays simple (cyclomatic complexity at most 3 after the change) is glanced at. So is a line outside every function, a declaration, a field, a constant, unless it branches itself. A line in a function that gained a branch, or in a function that was already complex, is read closely. The check of a rename and the look at where a block went are glances too. Reading time counts twenty lines read closely a minute, sixty glanced at.

glance at read closely reach.go type Reach struct 6 lines of fields, no function: glance Files() new, complexity 1 one path, nothing to decide: glance parse() complexity 4 → 7 three new decisions: read closely 18 code lines: 9 to read closely, 9 to glance at
The rule holds whatever the file: a complex function in a leaf file is read, a getter in a hub is glanced at. Measured on the bench, functions that gained no decision and stay simple are repaired half as often as the others; the reach of a file made no difference.
TypeSpecifier.php git: 2,636 lines deleted moved LegacyTypeSpecifier.php git: 2,636 lines added Cribelo: 124 lines to read
A real split in phpstan. Git sees one deletion and one addition, no rename. Cribelo sees the functions that moved and the 124 lines that changed on the way.

Facts

The second pass looks for what the diff does not show. A fact is something a reader can check in a minute. Cribelo computes the import graph of the repository before and after the change and compares them, asks git for the history of each touched function, and reads your coverage report. None is a prediction. At most one fact of each kind per file.

Before billing.go invoice.go tax.go billing imports invoice, invoice imports tax After the change billing.go invoice.go tax.go new import introduces a cycle: tax.go → billing.go → invoice.go → tax.go
The graph before the change is rebuilt from the base version of every changed file. Tests are left out of both graphs.

introduces a cycle

A new import of the file leads back to the file. Barrels (index.ts, __init__.py) and Rust mod declarations do not count. One fact per cycle, three at most.

depends on a module for the first time

A directory imports a directory it never imported. A directory that did not exist before says nothing: it depends on everything for the first time.

first use of a library

A package no file of the repository imported before. The standard library and the packages the repository defines itself do not count.

went from 2 to 3 parameters, called from N files

An exported function changed its signature. The files that call it are counted, so you know how far the change reaches.

new exported functions called nowhere yet

Exported functions the change adds and no other file calls yet: either the callers come in a later change, or the function is dead on arrival.

similar to a function the change does not touch

A clone of a touched function, left as it was. Either both needed the change, or neither did.

fixed N times in the past year

git log -L on the lines of the function. One line per file, the most repaired functions first. A function fixed twice is worth a second look.

changed lines not run by any test

Read from the coverage report of your last test run: lcov, Cobertura, Clover or a Go profile. Stated from ten uncovered lines, or half of the changed ones.

Order

The third pass puts the files in reading order. The files to read are grouped. Two files are in one group when one imports the other, or, in one directory, when one calls a function the other declares. Inside a group, a file comes before the files that use it: you read the definition before its uses, the rule of Baum and colleagues (2017). Each test comes right after the file it exercises, and a test that sits next to changed code is matched in its own directory only. Groups carrying a cycle, a signature change, a fix record, a first dependency or a clone come first, then the best score, then size.

uses token.go 1 token_test.go 2 parser.go 3 uses cli.go 4 uses Read what is used before what uses it. A test follows its file.

Where the score comes in

Four things shape the plan. Each one settles only what the previous ones left open, and the score comes last.

  1. NaturesWhat is read at all, and how many lines of each hunk. A file whose hunks are all mechanical never enters the list, whatever its score.
  2. FactsWhich group comes first: a cycle, a signature change, a fix record, a first dependency or a clone pulls its group to the top.
  3. DependenciesThe order inside a group: the definition before its uses, each test right after the file it exercises.
  4. ScoreWhat is left: which files fit a budget, the tie between two groups, and the high, medium and low levels shown in the table. Ten public signals, weights you can recalibrate on your own history.

Under a budget

cribelo --budget 30m fills the time with the best-ranked files first, at twenty lines read closely a minute and sixty glanced at, and adds a test only while it fits. What does not fit is listed under Skip as ranked lower, with everything it knows about it.

010 min20 min30 min token.go test parser.go util.go cli.go budget does not fit: skipped

The plan

cribelo prints the plan in the terminal. cribelo describe prints the same in Markdown, ready for the pull request description. The GitHub Action posts one comment per pull request and updates it on each push. Here is the plan of pull request #196 of AST Metrics, trimmed to its first files.

$ cribelo
cribelo · 34 files changed vs origin/main (merge-base b2f83dc)

Read 11 of 34 changed files, about 43 min.

                                                           lines to read closely               lines to glance at
   1  internal/analyzer/library_reach.go             new   30-35, 66-265                       16-26, 39-57
   2  internal/analyzer/library_conformance_test.go  test  98-140                              25-93
   3  internal/analyzer/library_reach_test.go        test  10-63, 85-144                       67-80
   4  internal/engine/util.go                              85-112
   5  internal/analyzer/file_dependency_analyzer.go        97-101, 122-127, 141, 152, +4 more  22-27
   6  internal/engine/php/deps/resolver.go           new   25-43, 57-69                        15-22, 46-51, 75-76
   7  internal/engine/php/deps/resolver_test.go      test  11-54
   8  internal/command/who_uses_command.go           new   52-241                              25-48
      … 3 more

Why these files
                                   complexity  used by    commits 90d      clone  test changed
   1  library_reach.go             ▮▮▮▯▯ 17    ▮▮▮▮▮ 83   ▮▮▯▯▯ 3                 ✓
   4  util.go                      ▮▮▯▯▯ 10    ▮▮▮▮▮ 111  ▮▯▯▯▯ 2, 1 fix          ✗
   5  file_dependency_analyzer.go  ▮▮▮▮▮ 44    ▮▮▮▮▮ 83   ▮▮▯▯▯ 3                 ✓
   6  resolver.go                  ▮▮▯▯▯ 9     ▮▯▯▯▯ 1    ▮▯▯▯▯ 1          84%    ✓
   8  who_uses_command.go          ▮▮▮▯▯ 18    ▮▮▯▯▯ 3    ▮▮▯▯▯ 3, 1 fix          ✓
      … 2 more

Totals  672 lines to read closely, 272 to glance at (20 and 60 lines a minute)
Set aside in the whole change: 11 import hunks.

Worth knowing  true by construction, not predicted
  internal/analyzer/aggregator.go  internal/analyzer depends on internal/engine/php/deps for the first time
  internal/engine/typescript/deps/resolver.go  internal/engine/typescript/deps depends on internal/engine/typescript/module for the first time
  internal/analyzer/library_reach.go  15 of 161 changed lines not run by any test, according to pr196-coverage.out
  internal/command/who_uses_command.go  49 of 162 changed lines not run by any test, according to pr196-coverage.out
  internal/engine/util.go  25 of 25 changed lines not run by any test, according to pr196-coverage.out

Skip (23)
  11 ranked lower · 8 tests only · 4 no measurable change    cribelo skip

The first table says where to read: for each file, the line ranges to read closely and the ones a glance is enough for. In the pull request comment, each range opens the diff on those lines. Under it, each column is a measure you can check: cyclomatic complexity of the touched functions, files that import this one, commits and fix commits in the last ninety days, similarity with another function, a test changed with the file.

A gate on what is measured

Nothing here fails a build on its own. In the Action, fail-over: 1h fails the job when reading the whole change would take more than an hour, every file counted, budget or not: the change is too large for one reviewer. fail-on: cycle, signature fails it when the change introduces an import cycle or changes an exported signature that other files call; any kind of fact can be named. The job says why in one line, such as "reading time 1 h 17, over the 1 h allowed". The score never closes a gate: a level is an estimate, a gate needs a measure.

How the relevance of Cribelo was measured

A reading map cannot be scored by a benchmark, so Cribelo was run on history. Twenty-eight open-source repositories in seven languages (Go, Rust, Python, TypeScript, PHP, Java, C#), their last 2,500 merges each: every merge is replayed against its parent with cribelo backtest, as if Cribelo had been there, and the plan it would have printed is kept. Four questions follow, each with the measure that answers it.

How much of a change is mechanical?

For every replayed change, the share of changed code lines still to read once moves, renames, formatting and imports are set aside, and the share of files where nothing is left to read.

LanguageChanged linesLeft to readFiles with nothing to read
C#1,815,31494.9 %14.0 %
Go454,64892.9 %14.4 %
Java416,04584.6 %20.3 %
PHP816,82492.8 %5.3 %
Python200,64888.0 %27.2 %
Rust364,31785.2 %18.6 %
TypeScript411,25987.5 %18.0 %
All4,479,05591.6 %14.4 %

On an ordinary month of pull requests, 8 % of the changed lines and 14 % of the files need no reading. The share climbs on the changes that drown a reviewer. In ruff, the pull request that splits types/class.rs changes 10,595 lines: 8,424 are moved, 3,678 are left to read, and git sees no rename. In phpstan, 2,636 lines land in LegacyTypeSpecifier.php and 124 are left to read.

Does the glance rule point at the right lines?

A reading rule can be checked without a reader, with the future: a function whose new lines were touched by a later bug fix deserved a close read. The same repositories, their last 1,500 merges, replayed function by function: 130,998 touched functions, 2,549 of them repaired later. The table reads: among the functions that gained no decision and ended at complexity 2 or below, 1.2 % were repaired later; among those that gained three decisions or more and ended above 10, 7.3 %.

Decisions gainedComplexity afterShare of linesFunctions repaired later
01 or 250.2 %1.2 %
03 to 55.3 %1.7 %
06 or more9.8 %1.9 %
1 or 2any12.7 %2.9 %
3 or more3 to 55.2 %4.0 %
3 or more6 to 106.7 %4.5 %
3 or more11 or more10.0 %7.3 %

The rule Cribelo ships, no decision gained and complexity 3 or below after the change, glances at 52 % of the lines inside touched functions. The functions it glances at were repaired 1.3 % of the time, the ones it reads closely 2.8 %. A third of the functions repaired later sit in the glanced half, which is why a glance is a fast read and never a skip. The reach of a file did not help: functions in files no other file imports were repaired 2.5 % of the time, functions in files imported by twenty or more 1.4 %. A test touched in the same change made no difference either, 2.1 % against 1.9 %. With the lines outside any function, the glanced share of a change runs from a third in caddy and the GitHub CLI to nine tenths in spring-boot and bitwarden.

Do the facts land where reviewers looked?

Four sets of 25 pull requests with human review comments, from prometheus, pydantic, cargo and vite, replayed at the commit of the first comment. 17 % of the changed files received a comment. The table counts, for each kind of fact, how many of the files it was stated on received one: a fact that lands on commented files far more often than 17 % of the time points at what reviewers care about.

FactFiles with the factCommented by a reviewer
similar to an untouched function54
fixed N times in the past year3217
new exported functions called nowhere125
signature changed4414
introduces a cycle72
depends on a module for the first time112
first use of a library30

Clone, fix history and unused API are far above the 17 % base rate, signature above it. Cycle, dependency and library are facts about the structure; reviewers do not comment on them file by file, and they are not there to predict that.

Does the order put the commented files first?

On the same hundred pull requests, share of the commented files among the first three files of the list: Cribelo 45 %, alphabetical order 33 %, largest files first 51 %. The order of Cribelo puts the definition before its uses so that the reader understands what follows; this proxy does not measure understanding.

Not measured

Limits

Cribelo does not find bugs and never says a file is safe. It says what is left to read, what it knows for sure about the structure, and in which order to read. It does not know your layering rules either: a dependency from the domain to the infrastructure is a first dependency like any other. The score that settles the budget and the tie between two groups comes from ten signals (lines, complexity, coupling, fixes, churn, tests, clones); its weights are public, and cribelo calibrate learns them from your own history. Every rule, threshold and weight is in docs/how-it-is-computed.md.