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.

MetricLimitScopeAnchor
cognitive15functionSonarSource default
cyclomatic15functionlizard default; MISRA and NASA safety-critical ceiling
abc40functionbetween RuboCop AbcSize 17 and Flog's "60 and above is dangerous"
nargs5functionRuboCop ParameterLists 5; Code Climate is stricter at 4
nexits5functionCode Climate return-statements 4
halstead.effort50000functionnone published; percentile-derived
loc.ploc600filenone published; percentile-derived
loc.sloc1200filebloat backstop, not the working limit
nom30containerCode Climate method-count 20; PMD TooManyMethods 10
wmc60containernone 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.

Languagecognitivecyclomaticabchalstead.effortloc.ploc
Bash35256095000(thin sample)
C5030602000003500
C++14102550000(thin sample)
C#44139500700
Elixir5720150001500
Go2517451200001500
Groovy16132530000450
Java751615000800
JavaScript20153555000(thin sample)
Kotlin1082020000300
Lua35204085000800
Objective-C18123560000(thin sample)
Perl3525451400001500
PHP1092550000900
Python20132525000700
Ruby761910000400
Rust882035000900
Tcl45258590000(thin sample)
TypeScript20132555000450
TSX14106595000400

"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 limithard offenderssoft limit (0.95)soft offenders
7 (kept)276.6560
6 (proposed)605.7134

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.