Skip to main content
Finds quick performance wins: patterns that are known to waste time, ranked by how often each one runs. A .Contains on a List<T> costs the same wherever you write it — but three loops deep inside a request handler it runs a hundred times per call, and in Program.Main it runs once. roe perf sorts by that difference, so the top of the report is where the time actually goes.
Each finding gets three lines: what and where, how often it runs and how sure roe is, and what to do about it. The fix is the reason the command exists — a ranked list nobody can act on is just a slower way to read the source.
roe perf is not part of a bare roe or roe check. It’s a separate command you opt into, so upgrading roe can’t change what an existing CI gate reports.

Options

The checks

Generated files are excluded from every check, as are files matched by the config’s ignore globs.

Why the database checks work without types

roe never compiles the code it reads, so it has no type information. It doesn’t need any here: Entity Framework’s async method names are unambiguous. Nothing but a database library defines ToListAsync, FirstOrDefaultAsync, SingleAsync, AnyAsync, CountAsync, FindAsync, or SaveChangesAsync. Dapper (QueryAsync, QueryFirstOrDefaultAsync, ExecuteAsync) and raw ADO (ExecuteReaderAsync, ExecuteScalarAsync, ExecuteNonQueryAsync) add a few more names to the same list. The sync versions — First, ToList, Any — are ordinary LINQ over in-memory sequences and are deliberately never matched. A check that fired on those would fire on half of every codebase. What splits n-plus-one-query from loop-invariant-query is whether the call mentions anything from the loop, and the two fixes are opposites:

Declared types, and the silence when there isn’t one

list-lookup-in-loop and string-concat-in-loop read the type as written in the source — field, property, and parameter declarations, explicitly typed locals, and var bound directly to a new. There is no inference and no flow between methods. List<T>, IList<T>, IReadOnlyList<T>, Collection<T>, ObservableCollection<T>, ImmutableList<T>, ImmutableArray<T>, ArrayList, and arrays all scan. HashSet<T>, Dictionary<K,V>, ISet<T>, IDictionary<K,V>, and their sorted, frozen, immutable, and concurrent variants don’t, and are never flagged. A name roe hasn’t seen declared produces no finding at all:
That silence is the point. These two checks are the ones most likely to be wrong, and staying quiet when roe can’t tell is what keeps them worth reading. IEnumerable<T> is treated as unknown for the same reason: LINQ’s Contains forwards to ICollection<T>.Contains when the runtime value implements it, so an IEnumerable<T> holding a HashSet<T> isn’t a linear scan and roe can’t see which it’s holding.

How findings are ranked

Every finding carries three separate numbers. Keeping them apart is what makes the ranking honest. Cost is how much runtime the pattern wastes each time it executes. It’s fixed per check: Severe (100), High (10), or Moderate (3). Heat is how often it runs — 10^depth, where depth is the number of loops around it inside its own method: Depth is capped at three. One pathological five-deep nest would otherwise score 100,000× and own every slot in the report, which is the failure mode this command exists to avoid. Score is cost × heat, and that’s the sort order. A moderate finding two loops deep (3 × 100 = 300) outranks a severe one that runs once (100 × 1 = 100), which is the whole argument for ranking rather than listing.
A lambda passed to a per-element LINQ operator — .Select(x => …), .Where(…), .Sum(…), .OrderBy(…) — counts as a loop level. It is a loop, and a linear scan inside one is just as quadratic as it would be inside a foreach. A lambda passed to anything else (Task.Run, an event handler, a DI registration) runs an unknown number of times and isn’t counted.
--sort path groups a file’s findings together instead, which is stable regardless of the scores and is the better choice when diffing two runs. The breakdown line in the footer always stays in ranked order.
Heat counts loops within one method. A method that’s cheap on its own but called from inside three loops elsewhere is not yet ranked as hot — that needs heat to propagate across calls, which is a later release.

Confidence and exit codes

Confidence is how sure roe is that a finding is real, and it decides the exit code and nothing else: A .Result is a .Result; there’s nothing to argue about, so it fails the build. A List<T>.Contains in a loop is roe reading a declaration and drawing a conclusion — worth your attention, but not worth blocking a merge over. The footer says which is which, and explains a passing run that still found things:
Confidence deliberately stays out of the ranking. A medium-confidence finding running 1000× per call is still the most valuable thing in the report, and burying it under a certain-but-cold one would defeat the point of ranking at all. --confidence high narrows what’s reported — in both human and JSON output — without changing the exit code, since only high-confidence findings ever gate. It’s the flag to reach for when adopting roe on a codebase that already has a backlog.

Turning checks off

Any check can be disabled by name in a config file:
roe.json
A name that isn’t a check is a warning, not a silent no-op — believing a check is off while it’s still failing your build is a worse outcome than a noisy startup:
Disabled checks are named in the footer and in --format json, so “clean” and “not looked for” never read the same:

Suppressing findings

Individual findings take inline suppression comments, using the check names from the table above:
Unlike unused-file and large-file, no perf check is file-wide: every finding sits on a real line, so a marker only ever covers the line it names.

What “scanned” counts

The footer’s N project(s), N file(s) scanned counts what was eligible to be reported, not what was parsed. Test projects under --exclude-tests and files matched by the config’s ignore globs are subtracted, and named on a line of their own:
The line only appears when something was actually excluded, so its presence is the confirmation that a setting took effect — which matters most when the setting came from a config file, where there’s no command line to eyeball. Generated files are never reported as an exclusion: they aren’t eligible under any setting, so there’s no setting for a reader to confirm.

What roe perf can’t see

Without type information some real performance problems are out of reach entirely. The most commonly asked-about one is lazy loading:
That’s an N+1 with no method name to match on — Lines is a navigation property, and whether touching it hits the database depends on the EF configuration roe never sees. Boxing, Span<T> opportunities, missing AsNoTracking, collection capacity hints, and ConfigureAwait are out of reach for the same reason. See Known limitations.