Choosing thresholds
bca check compares each metric against a limit you configure. This page explains where the
shipped defaults come from, how to adjust them for the language you are gating, and how to pick a
different set for a different job. It is for anyone editing a [thresholds] table, whether by hand
or through a coding agent.
If you have not set up a gate yet, start with Local threshold gates for the mechanics and Baselines for absorbing the offenders you already have. This page is about the numbers.
The shipped defaults
bca init scaffolds this table. It is defined once in
big-code-analysis-cli/src/default_thresholds.rs.
| Metric | Limit | Scope | Anchor |
|---|---|---|---|
cognitive | 15 | function | SonarSource default |
cyclomatic | 15 | function | lizard default; MISRA and NASA safety-critical ceiling |
abc | 40 | function | between RuboCop AbcSize 17 and Flog's "60 and above is dangerous" |
nargs | 5 | function | RuboCop ParameterLists 5; Code Climate is stricter at 4 |
nexits | 5 | function | Code Climate return-statements 4 |
halstead.effort | 50000 | function | none published; percentile-derived |
loc.ploc | 600 | file | none published; percentile-derived |
loc.sloc | 1200 | file | bloat backstop, not the working limit |
nom | 30 | container | Code Climate method-count 20; PMD TooManyMethods 10 |
wmc | 60 | container | none published; percentile-derived |
Scope matters when you read these. A cognitive limit applies to each function; nom and wmc
apply to each class, struct, trait, impl, namespace, or interface; loc.* applies to the whole-file
root. bca check will not compare a container's method count against a per-function limit. See
Check for the full scope rules.
Two of these limits deserve their reasoning spelled out.
loc.ploc and loc.sloc are a pair. PLOC counts physical lines of code with blanks and comments
excluded, so it is the working file-size limit: growing a file by documenting a decision costs
nothing against it. SLOC counts everything, so it sits far looser and does one job only, which is
stopping a file from growing without bound on comment volume while still clearing loc.ploc. Gating
file size on SLOC alone charges the same price for a paragraph of rationale as for a new branch, and
the observable result is that people delete the rationale.
cyclomatic at 15 rather than McCabe's 10 is a deliberate loosening.
NIST 500-235, the
document that made 10 the canonical number, allows raising it for teams with the process to justify
it, and observes that limits as high as 15 have been used successfully. Measured against real code,
10 flags roughly twice as many functions as 15 without a corresponding change in what a reviewer
would call a problem.
How the defaults were derived
Published thresholds disagree with each other by wide margins. For cyclomatic complexity alone the
defaults in common tools span 7 (RuboCop) to 30 (gocyclo), with Checkstyle and PMD at 10, lizard
and MISRA at 15, and ESLint at 20. Picking one by authority means picking an authority.
So each limit here is checked against measurement as well. The reference corpus is 43 real-world
repositories across 20 languages, cloned at their default branch on 2026-07-31, with test trees,
vendored code, and generated files excluded. Measured with bca metrics, that is roughly a quarter
of a million function spaces, forty thousand container spaces, and twenty-seven thousand files.
Values are binned by the same File, Function, and Container scope bca check applies, so a
container's nom never lands in a per-function distribution.
The design rule is that a default should flag roughly the worst 1% to 3% of spaces in the median language. Below that a limit is inert and gives false comfort. Above it the gate stops being a gate and becomes a style rule that people route around. This is a stricter reading of the benchmark approach in Alves, Ypma, and Visser, who derive risk bands at the 70th, 80th, and 90th percentiles. Their 90th percentile is a useful "worth looking at" line; it is far too noisy to fail a build on.
Two of the previous defaults failed that rule and changed as a result. nargs at 7 fired on under
1% of functions in the median language and on nothing at all in this project's own source, which
makes it a limit that cannot catch anything. cognitive at 25 was inherited from
clippy, which is
deliberately conservative because it lints rather than gates; 15 is what the metric's designers
chose.
Per-language overrides
Metric distributions vary by language more than by project. The 90th-percentile per-function
cognitive value in the reference corpus runs from 0 in C# to 21 in Tcl. Median file loc.ploc
runs from 16 in TypeScript to 283 in C, an eighteen-fold spread. A single table cannot fit both
ends.
The table below gives the measured 97.5th percentile per language, which is the value a limit would need to flag the worst 2.5% of that language's code. Read it as calibration data, not as a prescription: a low number means the language's code is mostly simple, which is a reason you can tighten, not evidence that you should.
| Language | cognitive | cyclomatic | abc | halstead.effort | loc.ploc |
|---|---|---|---|---|---|
| Bash | 35 | 25 | 60 | 95000 | (thin sample) |
| C | 50 | 30 | 60 | 200000 | 3500 |
| C++ | 14 | 10 | 25 | 50000 | (thin sample) |
| C# | 4 | 4 | 13 | 9500 | 700 |
| Elixir | 5 | 7 | 20 | 15000 | 1500 |
| Go | 25 | 17 | 45 | 120000 | 1500 |
| Groovy | 16 | 13 | 25 | 30000 | 450 |
| Java | 7 | 5 | 16 | 15000 | 800 |
| JavaScript | 20 | 15 | 35 | 55000 | (thin sample) |
| Kotlin | 10 | 8 | 20 | 20000 | 300 |
| Lua | 35 | 20 | 40 | 85000 | 800 |
| Objective-C | 18 | 12 | 35 | 60000 | (thin sample) |
| Perl | 35 | 25 | 45 | 140000 | 1500 |
| PHP | 10 | 9 | 25 | 50000 | 900 |
| Python | 20 | 13 | 25 | 25000 | 700 |
| Ruby | 7 | 6 | 19 | 10000 | 400 |
| Rust | 8 | 8 | 20 | 35000 | 900 |
| Tcl | 45 | 25 | 85 | 90000 | (thin sample) |
| TypeScript | 20 | 13 | 25 | 55000 | 450 |
| TSX | 14 | 10 | 65 | 95000 | 400 |
"Thin sample" marks a language whose file count in the corpus is too small to derive a file-size figure from. The per-function figures for Bash, Tcl, JavaScript, and Objective-C rest on the smallest samples in the table and are the ones most worth re-deriving against your own code.
What to change, and why
Most languages need no override. The cases that do fall into three groups.
Procedural languages with large dispatch functions. C, Tcl, Bash, Lua, Perl, and Go all run two
to three times the defaults. These languages lack exceptions or discourage them, so error handling
is inline branching, and they favour long switch-style dispatch over polymorphism. The default
cognitive limit flags 5% to 15% of their functions, against 3% in the median language. Raise
cognitive and halstead.effort first; those two carry most of the excess.
In a single-language repository this is the whole [thresholds] table; in a mixed one it belongs
under the language it describes, or it drags every other language up with it.
# Gating C. The same shape suits Tcl, Bash, Lua, Perl, and Go.
[thresholds.lang.c]
cognitive = 30
cyclomatic = 20
abc = 50
"halstead.effort" = 120000
"loc.ploc" = 1200
"loc.sloc" = 2000
Languages whose module construct is not a class. nom and wmc are Container-scoped, and a
container is whatever the grammar calls a class, struct, trait, impl, namespace, or interface. An
Elixir defmodule holds dozens of functions by design, so roughly a third of Elixir modules breach
nom = 30. That is a scope mismatch, not a code smell. Rust sits at the other end: an impl block
is usually a handful of methods, so the default never fires.
# Elixir modules and Rust impls sit at opposite ends of the same metric.
[thresholds.lang.elixir]
nom = 150
wmc = 300
[thresholds.lang.rust]
nom = 20
wmc = 40
Compact, heavily-abstracted languages. Java, Ruby, Kotlin, C#, and Elixir sit far under the
defaults on every per-function metric. Their 97.5th percentile cyclomatic is 4 to 8. Gating them
at 15 means the gate will effectively never fire, so tighten if you want it to do work.
Read the C# row with care. Its distribution is dominated by property accessors, which bca counts
as functions and which are almost always trivial, so its percentiles sit lower than the code a
reviewer would actually be looking at. The same effect applies more weakly to Java, Kotlin, and
Ruby. Prefer re-deriving from your own repository over adopting any of those four rows directly; see
Re-deriving for your own codebase.
Metrics that do not apply to every language
nargs reports 0 for every Bash function, and that is correct and permanent: the shell has no
formal parameter list, arguments arrive as $1, $2, and so on, and the limit is simply inert. A
nargs limit therefore passes unconditionally on a Bash codebase, which reads as "no offenders"
rather than "not measured". Bash is the only language left in that position.
Perl is counted, but only where the source declares a signature (sub add($x, $y), stable since
5.20 and on by default under use v5.36). A sub without a signature takes its arguments from @_
and has no formal parameter list to measure, so it reads 0 — correctly. Perl's nargs distribution
is therefore sparse on codebases predating use v5.36, and one anonymous-sub form still reads 0
because the grammar misparses its signature.
wmc, npm, and npa are only produced for languages with a class-like container. They are absent
from Bash, C, Go, Lua, Perl, and Tcl output.
A per-language override cannot fix any of these. Bash nargs is 0 for every function, so no
limit — not even 0, which fires only on a value above it — can make the gate say anything. The
override mechanism tunes limits; it does not add a measurement the grammar cannot supply.
Applying overrides
Write them into bca.toml as [thresholds.lang.<slug>] tables. Each one layers over the project's
[thresholds] per metric, so a language inherits every limit it does not restate:
[thresholds]
cognitive = 15
cyclomatic = 15
nargs = 5
nom = 30
wmc = 60
# Procedural, dispatch-heavy: raise the two metrics carrying the excess.
[thresholds.lang.c]
cognitive = 30
cyclomatic = 20
"halstead.effort" = 120000
"loc.ploc" = 1200
# A defmodule is not a class; nom and wmc need module-sized limits.
[thresholds.lang.elixir]
nom = 150
wmc = 300
# Compact and heavily abstracted: tighten, or the gate never fires.
[thresholds.lang.csharp]
cognitive = 8
cyclomatic = 8
The key is the canonical language slug, the same vocabulary --language accepts (cpp, csharp,
objc, tsx, mozcpp, mozjs); an unknown one is a tool error rather than a silent no-op. A
language with no table of its own is gated by [thresholds]. Under --tier=soft each language's
soft band is derived from its own resolved limit, so a language that raises a limit gets a soft
band scaled from the raised number rather than the project-wide one. The full reference — including
how --print-effective-config renders the resolved tables — is in
bca check.
Two things to watch when splitting a table this way. .h is analyzed with the C++ grammar, not the
C one, so a C project's headers are gated by [thresholds.lang.cpp]; see
Supported Languages for the extension map. And --threshold on the command line
stays global — it overrides every per-language table too, which is what you want for a one-off run
and a surprise if you expected it to compose.
The alternative is still available and still worse: keep one table sized for the loosest language and let per-file baselines carry the rest. It is simpler to maintain and strictly weaker, because the stricter languages stop being gated at all.
Per-use-case profiles
The same codebase wants different limits depending on what the gate is for.
Blocking CI gate
This is what the shipped defaults are for. A limit that fails a pull request has to be one the team agrees is a real problem, because the cost of a false positive is a blocked merge and an argument. Pair it with a baseline so the gate fails only on new offenders and regressions, and tighten from there.
Use the shipped defaults unchanged, plus:
[check]
baseline = ".bca-baseline.toml"
Agent feedback loop
When the consumer is a coding agent rather than a human reviewer, a false positive costs one wasted refactor rather than a blocked merge, and the signal arrives while the code is still being written. Tighten toward the published per-function values, and drop the file-size and container limits, which an agent editing one function cannot act on.
[thresholds]
cognitive = 10
cyclomatic = 10
abc = 25
nargs = 4
nexits = 4
"halstead.effort" = 25000
See Feeding metrics to an agent for wiring this into an edit loop, and Suppression markers for telling the agent when complexity is essential rather than accidental.
Legacy audit and triage
When the goal is to rank an unfamiliar codebase rather than to gate it, you want the handful of
functions that are genuinely worst, not a list of thousands. Set limits at roughly twice the
defaults so only extreme outliers surface, run without a baseline, and use
bca report markdown rather than bca check.
[thresholds]
cognitive = 40
cyclomatic = 30
abc = 80
"halstead.effort" = 250000
"loc.ploc" = 1500
nom = 60
wmc = 120
Quality reports covers the report output. Adding --vcs ranks by change
history as well, which is usually a better triage order than complexity alone: a complex function
nobody has touched in three years is not where the bugs are.
Safety-critical and regulated
Standards in this space specify limits directly, and the standard wins over any measurement. MISRA
and NASA both cap cyclomatic complexity at 15; the
JSF C++ standard allows 20 with
a documented exception for large switch statements. McCabe's original 10 applies where the
testing budget supports it, since the number is a bound on the basis-path test count, not a style
opinion.
[thresholds]
cognitive = 15
cyclomatic = 10
abc = 30
nargs = 5
nexits = 1
"halstead.effort" = 25000
nexits = 1 encodes the single-exit rule from MISRA C:2023 Rule 15.5. It is contentious outside
regulated work, and in most codebases early returns make code simpler rather than harder to follow,
so treat it as a compliance setting rather than a general recommendation.
Metrics not gated by default
These are computed and visible in bca report markdown|html. They are left out of the default
table on purpose.
halstead.volume has the most widely-cited threshold of any Halstead measure, the guideline that a
function's volume should stay under 1000. Measured against the corpus it flags about 7% of functions
in the median language and 20% in the worst, which makes it useful for ranking and unusable as a
gate.
mi.original, mi.sei, and mi.visual_studio are the Maintainability Index family, and they are
lower-is-worse: the violation is a value below the limit. Visual Studio's bands (below 10 poor, 10
to 19 moderate, 20 and above good) apply to its own rescaled 0 to 100 output, which bca reports as
mi.visual_studio. mi.original is unbounded above and reaches 167 on the corpus, so the original
SEI bands of 65 and 85 do not transfer. The index is also a
function of the metrics you are already gating, so gating it too double-counts. See
Supported Code Metrics for the formulas.
npm, npa, tokens, and cyclomatic.modified are omitted because each duplicates something
already in the table, or because their distributions are dominated by a language idiom rather than
by design quality.
Tightening a limit onto a cluster
Converging a limit onto a cluster of existing values is never free while a proportional soft tier is active. Measure a candidate at both tiers before calling it free — the hard-tier reading is the one you naturally take, and it is the one that misleads.
The soft tier measures distance to the limit. A limit chosen to sit exactly on a population's value therefore maximises soft-tier breach by construction: every function at that value is inside the band the moment the limit lands, and no amount of tidying moves it out, because it is the limit.
This project walked into it. nargs was at 7, and tightening it to 6 looked free — every
offender at the tighter limit was already in the baseline, so bca check --threshold nargs=6
reported nothing:
nargs limit | hard offenders | soft limit (0.95) | soft offenders |
|---|---|---|---|
| 7 (kept) | 27 | 6.65 | 60 |
| 6 (proposed) | 60 | 5.7 | 134 |
Seventy-four functions sit at exactly 6. A limit of 6 puts all of them 0.3 below the soft
band at once — 74 baseline entries bought for no hard-tier gain, none of which can ever be retired
short of rewriting the signatures. The limit stayed at 7; the honest alternative was the real
6 → 5 work. (#1143,
#1169.)
bca check --explain-threshold <metric>=<limit> measures both tiers in one walk and names the
cluster when it finds one, without touching bca.toml:
$ bca check --explain-threshold nargs=6
nargs: candidate limit 6
hard tier (limit 6): 60 offenders, 60 already baselined, 0 new
soft tier (limit 5.7, 0.95x): 135 offenders, 61 already baselined, 74 new
cluster: 75 of 75 soft-band offenders sit at exactly 6 — the candidate limit itself. …
The trap is not confined to the global table. A
per-language override is another way to converge a limit onto a population, and it
has the same cliff — --explain-threshold reports a language keeping its own limit on a separate
line rather than folding it into the count.
The rule generalises past the soft tier: a limit that lands on a cluster leaves the population with no room, so the next tightening is the one that has to move real code. Prefer a limit in the gap between clusters. See Preview a candidate limit for the flag's full contract.
Re-deriving for your own codebase
Corpus percentiles are a starting point. Your own distribution is better evidence, and producing it takes one command plus a short script.
bca metrics --no-config -O json -I '*.rs' -X '**/tests/**' . > metrics.jsonl
Each line is one file's FuncSpace tree. Walk it, keep each space's own value for the metric you
care about, filter by kind to match the metric's scope ("function", "unit" for loc.*, or a
container kind for nom and wmc), and take the 97.5th percentile. Setting a limit there flags
your worst 2.5%.
Two checks are worth running afterwards. Count the offenders the limit produces: if it is more than
a few percent of your spaces, the gate will be ignored rather than obeyed. Then look at where the
values pile up. A natural distribution tails off smoothly, so a cluster of files sitting just under
the limit means the limit is shaping the code rather than measuring it, and people are trimming to
fit. That happened to this project's own loc.sloc gate, which is documented in
issue #1138.
bca check --write-baseline records today's offenders so you can adopt a stricter limit without
failing on day one. Baselines covers the bootstrap, refresh, and retire flow.