Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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, manually 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. The anchor column cites MISRA and Flog alongside the tools linked inline.

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 need 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, as measured for #1140, was 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. The original corpus measurement found that nargs = 7 flagged fewer than 1% of functions in the median language, and nothing at all in this project’s own source: a limit that cannot catch anything. That measurement includes nested-closure parameters, which #1196 later removed from nargs; the current own-parameter metric cannot produce a higher count on the same inputs, and the drop is largest for closure-heavy languages, so the conclusion holds. The shipped limit is therefore stricter. 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
Bash35256044000(thin sample)
C5030601900003500
C++14102554000(thin sample)
C#441344000700
Elixir5720150001500
Go251745890001500
Groovy16132525000450
Java751620000800
JavaScript201535170000(thin sample)
Kotlin1082018000300
Lua35204082000800
Objective-C18123577000(thin sample)
Perl352545410001500
PHP1092526000900
Python20132542000700
Ruby76195000400
Rust882034000900
Tcl45258546000(thin sample)
TypeScript20132560000450
TSX141065130000400

“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.

The halstead.effort column comes from a different measurement than the rest of the table. It was re-measured on 2026-10-10, after the Halstead vocabulary changes in #1486 through #1573, on a rebuilt corpus of 41 repositories that is comparable to the original but not identical to it. The C, C++, and Objective-C rows were re-measured on 2026-10-15 to include the declaration modifiers of #1578 and #1579. The repository list and commits are recorded in docs/development/threshold_corpus.md. Several rows moved by a factor of two to five, and almost all of that movement came from the change of repositories, not from the metric: the same 41 repositories measured with the pre-change release differ by at most half (Python) and mostly not at all. Treat any single row as a property of the repositories that represent the language, which is one more reason to re-derive against your own. In this column the thinnest samples are Objective-C, Perl, Bash, and C++, each under 2,500 functions.

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 cognitive default and above the cyclomatic one. 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 first. halstead.effort carries the rest of the excess for C, Go, and Lua; Tcl, Bash, and Perl measure close to the default on it.

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. Tcl, Bash, Lua, Perl, and Go share its cognitive and
# cyclomatic shape, but not its effort: Go and Lua measure near 90000,
# and Tcl, Bash, and Perl near the default.
[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 except C#’s halstead.effort. Their 97.5th percentile cyclomatic is 4 to 8. Gating them at 15 means the gate will almost 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 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 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, Lua, Perl, and Tcl output. Go is a half case: it has no container space at all, so wmc is absent and npm / npa are reported only on the file unit root. bca check gates both on container spaces and never on the root, so no npm or npa limit can fire on Go source; see Which spaces carry NPA, NPM and WMC.

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 weaker, because the stricter languages stop being gated at all.

Per-path overrides

A language table tunes a limit for everything written in that language. Some populations of files are a different shape from the rest of their language, and the shape is a property of the directory, not the grammar: a tree of hand-rolled dispatch tables, a generated binding layer, a parser whose state machine is one function per state. The cyclomatic count of a dispatch table is the length of its construct list, and its Halstead effort is the size of its vocabulary; neither number tells a reader anything a split would improve, and the project limit misreads every function in the directory the same way.

The per-function answer is a suppression marker on each one, and it does not scale. This repository accumulated 38 bca: suppress markers naming cyclomatic and Halstead across its per-language dispatchers, in several wordings and two operand orders, with nothing stating that the directory was exempt as a policy. A [[thresholds.path]] entry states the policy once:

[thresholds]
cyclomatic = 15
"halstead.effort" = 50000

# The per-language dispatchers are exhaustive kind tables: their
# cyclomatic is the construct list and their effort is the vocabulary.
[[thresholds.path]]
glob = "src/metrics/{abc,cognitive,cyclomatic,loc}/*.rs"
cyclomatic = 40
"halstead.effort" = 150000

An entry is an array element, so a manifest can carry several with different globs. Each names a glob and the metrics it re-limits, and inherits every limit it does not restate. The glob is matched against the file’s path relative to the directory of the config that declared it (the bca.toml, or the --config file), with the same engine and the same anchor that [check] exclude uses, so one spelling describes the same files on both surfaces. A file outside that directory is matched by its seed-relative form, exactly as a [check] exclude glob matches it: the path under the --paths seed that produced it, so with bca check -p src an entry declared outside the tree sees src/gen/a.c as gen/a.c, and its src/** does not reach it. Where several entries match one file, the last entry wins, per metric, in declaration order — the manifest’s entries first, then the --config file’s — the rule .gitignore readers already know.

An override is not a suppression. The function stays in bca check’s output and in the baseline with its real value, and the soft tier scales the overridden limit the way it scales any other: at --tier=soft=0.9 the entry above warns at cyclomatic = 36, and the ceiling an offender is measured against for exit 5 is 40, not the project’s 15. The marker takes a function out of the gate; the entry keeps it in, at a limit chosen for its population. That is the whole difference, and it is why --explain-threshold can still price a candidate limit over those files: it lists each entry that declares the metric, with the number it declares, so the count is read against the right denominator. It reports the declaration rather than a per-entry limit because entries overlap — where two match one file, the later one’s number is the limit — and the offender counts it prints are already taken against each file’s own resolved limit.

Three things the parser refuses, each naming the entry by its position in its own file and its glob: a metric name [thresholds] does not know, an entry with no glob or an empty one ("" and ./ would match nothing), and tokens, which is a cap on what the walk will parse rather than a maintainability limit and is not overridable by any layer. --print-effective-config prints every entry under [[thresholds.path]] with the metrics it declares at their hard-tier limit — --threshold applied last, the tier not applied — so the printed file replays the gate at the hard tier through --config. Under --tier soft the printed path limits are the hard limits the soft band is derived from, not the band: the band belongs to a file’s whole match set (a second entry naming only another metric changes the first’s), so no per-entry row can carry it, and a printed band would be scaled a second time on the way back in. A soft-tier run is therefore not replayed by the printout alone — the view echoes no [thresholds.soft] table and cannot mark a --threshold limit as absolute — so the hard tier is the replay contract; see Per-path limits for what a same---tier reload does and does not reproduce.

How the layers compose

This is the one statement of the order; bca check --help and the bca check reference cite it rather than restating it.

  1. [thresholds] — the project table, from bca.toml with --config merged on top.
  2. [thresholds.lang.<slug>] — the file’s language, per metric.
  3. [[thresholds.path]] — every entry whose glob matches the file, in declaration order, per metric; the last match wins.
  4. The tier — [thresholds.soft] or the soft ratio — derived from the limits the three layers above resolved to, so each table’s soft band and hard ceiling come from its own numbers. An absolute [thresholds.soft] limit applies as written to every table, path entries included, so it must sit under each one’s hard limit — an entry that lowers a metric below the soft limit is rejected before the walk, naming the entry.
  5. --threshold name=value — applied last and absolutely, to every table, never scaled.

A limit a --threshold flag names is therefore the limit that runs, for every file, whatever the tables say; and a limit only a path entry names is still computed for every file that entry matches, so the gate it adds can fire.

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 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 present in every bca metrics output format; bca report markdown|html shows only a subset of them. 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 inside 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.)

Those figures were measured before #1196, when the nargs gate summed a function’s own parameters with every nested closure’s. Most of that population was not parameter-heavy at all: of the 76 functions a limit of 5 would have newly gated, only 17 had six or more parameters of their own, and one had a single parameter plus five contributed by closures in its body. Re-measured on the same tree under own-parameter gating:

nargs limithard offendersnew (unbaselined)soft offendersnew
7 (kept)60160
61603817
5381712999

The cluster trap itself is unchanged and is the reason this section exists: the re-measurement for #1143 found 91 functions sitting at exactly 5, so a limit of 5 still put every one of them permanently inside the soft band. What changed is the size of the real work behind it: signatures rather than mixed cases.

The convergence landed on that evidence. One thing measuring it clarified, which this section previously got wrong: the cluster effect is structural for every integer metric, not a property of landing on a population’s mode. A function sitting exactly at limit L is always above 0.95 x L, so it is always in the soft band. In that measurement the ten functions at exactly 7 were in it before the change, just as the ninety-one at exactly 5 were after. What the limit choice controls is only how many sit there. Read #1143’s objection that way: it was sound because the hard-tier gain was zero, not because 6 was near a mode.

That also means the soft tier carries little information for a small-integer metric, where 0.95 x L and L differ by less than one. It is doing real work for halstead.effort (50000 vs 47500) and almost none for nargs (5 vs 4.75, which flags every compliant function at the ceiling).

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%.

Run two checks 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.