Skip to main content
Flags code that’s hard to work with rather than code that’s unused: methods that are too complex or too long, types and files that have grown too big, and types that depend on each other in a cycle. Opt in to --hotspots to also rank the files that are both complex and frequently changed.
Truncated to the two worst findings with --limit 2:
Each declaration gets one entry listing every check it tripped, and each metric is printed as actual/threshold so you can see how far past the line it sits. The worst offenders come first.

Options

Every threshold can also be set persistently in a config file, which is usually what you want in CI rather than repeating six flags on each invocation.

The checks

Generated files are excluded from every check, as are files matched by the config’s ignore globs. A circular dependency that touches a generated type is dropped whole rather than reported with that type left out — like --exclude-tests, since a path with a hole in it would name edges that aren’t there.

?? is not a branch

The null-coalescing operator doesn’t count towards cyclomatic complexity. It’s a defaulting idiom, not control flow the reader has to trace — nobody writes a second test case for the null side of name ?? "anonymous". Counting it made hand-rolled with-style methods score wildly:
That used to score cyclomatic 14 against a limit of 10 while cognitive complexity — the metric that actually models comprehension — scored it 0. Now both agree it’s trivial. Any real branching hidden behind a ?? still shows up: the right-hand side is walked like any other expression, so Load() ?? throw new InvalidOperationException() or a ?? whose fallback contains a ternary is counted on its own merits. ??= never counted, since the grammar treats it as an assignment.
&& and || are unchanged and still cost one point each — that’s McCabe’s definition, and unlike ?? they genuinely add a path a test has to cover. Cognitive complexity continues to collapse a run of them to a single point.

What “scanned” counts

The footer’s N project(s), N file(s), N symbol(s) scanned counts what was eligible to be reported, not what was parsed. Anything ruled out up front — test projects under --exclude-tests, files matched by the config’s ignore globs (top-level or health.ignore) — is subtracted, and named on a line of its own:
The excluded: line only appears when something was actually excluded, so a plain run is unchanged. Up to three project names are listed; past that the line says +N more. This matters most when the exclusion comes from a config file, where there is no command line to eyeball: health.excludeTests: true is otherwise invisible, and the footer is the one place a reader can confirm both that the setting was picked up and that their test project was recognized as one.
Generated files are not reported as an exclusion. They are never eligible for any check, under any setting, so there is no setting for a reader to confirm — unlike --exclude-tests and ignore, which are choices that can silently fail to apply.

Sorting and severity

Severity is simply how many times over its threshold a finding sits — metric / threshold — which makes a cyclomatic complexity of 46 against a limit of 10 directly comparable to a 242-line body against a limit of 40. A declaration’s severity is the worst of its checks, and a file’s is the worst of its declarations. --sort severity (the default) puts the worst thing in the codebase first. --sort path groups by file path instead, which is stable regardless of the metrics and is the better choice when diffing two runs. Both are deterministic: severity ties break on location. --limit caps the human report only. When it bites, roe says how many findings it held back:
--sort and --limit are presentation only and don’t apply to --format json, which always emits every finding. Tooling does its own ordering, and a silently truncated array would be actively misleading.

Large types report a breakdown

Thirty auto-properties is a data holder; thirty methods is a god class. roe doesn’t guess which one you have — it prints the composition and lets you judge:
Enum cases don’t count towards a type’s size. An enum’s cases are its whole point, and counting them would report a 25-case enum as a god class. const fields don’t count either, for the same reason: a const has no behaviour and no state at runtime — it’s a name for a literal, inlined at every call site — so it can’t be part of the cohesion problem this check looks for. A class holding fifty tuning constants is a lookup table, not a god class. static readonly fields do still count: roe has no type analysis, so it can’t tell a static readonly float tuning value from a static readonly HttpClient that the type genuinely depends on.

Only required parameters count

The point of a parameter limit is call-site burden: how much a caller has to supply, and how much they have to keep straight while doing it. A parameter the caller can leave out costs them nothing, so it isn’t counted. A parameter is required when the caller has to pass something and think about what. That means: Findings print the whole picture, so nothing is hidden — the metric is the required count, and the declared signature follows in parentheses:
When every parameter is required, the parenthetical is dropped: repeating 6 declared: 6 required next to 6/5 params would be noise.
A method with a dozen out parameters is still worth a second look — it’s usually asking to return a record instead. roe just doesn’t report it under a check whose stated meaning is “too much to pass in”. The declared total is always printed, so a long signature is still visible in the report and in --format json.

Overloads

Overloads share a name, so two flagged rows could otherwise be indistinguishable. Where that collision actually happens, roe appends a Roslyn-style arity suffix — and only there, since an arity on a name with no overloads is noise:

Circular dependencies

A cycle is reported as a chain of real references, each type pointing at the next and the last pointing back at the first. Larger tangles usually contain more types than any single loop through them touches; those are listed separately rather than being spliced into the path, which would imply references that don’t exist:

Hotspots

--hotspots reads git history and ranks files by complexity multiplied by recent churn — the files that are both hard to understand and constantly being changed, which is where refactoring pays off most. Commits are weighted by recency on a 90-day half-life, so last month’s churn counts for more than last year’s.
Scores are relative: the riskiest file in the run scores 100 and everything else is measured against it, so they compare files within one run rather than across runs or repositories. Hotspots are informational and never affect the exit code — every codebase has a riskiest file, and failing a build over the existence of a ranking would make the check useless as a CI gate.
This is the one part of roe that reads git history, so the analysis root must be inside a git repository. If it isn’t, --hotspots fails with exit 2 rather than silently reporting nothing.

Baselines

Turning roe health on over a codebase that didn’t have it from day one means starting at a few hundred findings, which fails every build until someone fixes all of them. A baseline records what’s already there so CI can gate on new debt from the first day, and the existing debt gets paid down on its own schedule.
--write-baseline writes the file, reports what it recorded on stderr, and exits 0 without printing a report:
Commit that file. From then on, --baseline roe-baseline.json reports and exits on new findings only, and the footer says how many it hid:
The two flags conflict — writing a baseline through a baseline would record a filtered picture of the codebase.

The file

roe-baseline.json
Entries are written sorted by kind and then name, so the file diffs cleanly and a regenerated baseline shows only what actually changed. Unknown fields and unknown versions are rejected rather than ignored, the same way config files are.

What matches

A finding is hidden when its kind and name match a baseline entry. The line number is deliberately not part of the match: a baselined method stays baselined when something above it in the file grows by three lines, which is the whole point of recording it once. file is written for readability and diffing and isn’t matched on either. Cycles match on their set of member names, regardless of the order the path happens to be printed in.

Metric regressions still report

A matched finding whose metric is higher than the baselined value is reported anyway. A method going from cyclomatic 12 to 30 is new debt in an old place, and a baseline that hid it would let a rewrite land unexamined under the cover of an entry that was about something much smaller. Equal or lower stays hidden.

Stale entries

A baseline entry that no longer matches anything — the method was fixed, renamed, or deleted — is reported as a warning, never a failure:
Regenerating after a green run is the ratchet: fixed debt can’t come back under the entry that used to cover it.

In CI

The step passes while the codebase only carries its known debt, and fails the moment a pull request adds a finding that isn’t in the file. Set health.baseline in a config file instead if you’d rather a bare roe and roe check picked it up too.

Suppressing findings

Individual findings take inline suppression comments using the rule names high-complexity, high-cognitive-complexity, long-method, too-many-parameters, large-file, and large-type:
Circular dependencies span multiple files, so — like duplicates — they have no inline comment. Use ignore globs in a config file instead — health.ignore scopes the suppression to health alone, so the file keeps its dead-code and dupes coverage.