.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.
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 definesToListAsync, 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:
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 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
--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: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’sN 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:
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: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.