Local threshold gates
CI is the last line of defence, not the first. By the time
bca check (reading the repo-root bca.toml manifest and its
.bca-baseline.toml) fires red on a pull request, the offending
change has already been
pushed, the author has context-switched, and someone has to revisit
the diff to nudge a metric back under its limit. A local threshold
gate moves that feedback to the moment of git commit — the same
moment cargo fmt --check and cargo clippy -- -D warnings already
fire — so the regression never makes it past the developer's
keyboard.
This recipe captures the pattern big-code-analysis uses on its own
source (Makefile's self-scan* targets,
backed by a consolidated bca.toml
manifest) and distils it into something you can drop into your own
repo's Makefile, justfile, package.json script, or pre-commit
config. The underlying idea is provider-neutral: any threshold
checker (bca, ESLint, clippy, SonarLint, Qodana) can be wired the
same way.
Principles
Three principles drive the design. They are not specific to bca;
they are the same conclusions Sonar reached when it pivoted its
default Quality Gate to focus on
new code
and that the broader ratchet pattern formalises.
- Gate locally, mirror CI exactly. The local gate must run the same binary with the same arguments and the same threshold / baseline / exclude files as CI. If the local gate is "almost what CI runs", it stops catching regressions the moment one diverges from the other. The cost of running the gate once before pushing is cheap; the cost of a red PR-bot ping is not.
- Ratchet, don't reset. When you introduce thresholds on an existing codebase, every reasonable limit fires on dozens of pre-existing functions. The realistic adoption path is "absorb today's offenders into a baseline file, fail only on new or worsening ones, shrink the baseline over time". This is the same strategy that lets a multi-year codebase introduce strict TypeScript or strict clippy lints without a months-long boil-the-ocean pass. See the Baselines recipe for the bootstrap → CI → refresh → retire flow.
- Warn before you fail. A hard 100% gate fails at the limit
and gives no signal as a function creeps from 80% to 95% to 99%
of its threshold. A second, looser tier that fires at e.g. 95%
of every limit gives a one-or-two-commit early warning. The
author still has the file open, the test cases in their head,
and the freedom to refactor before the offender hardens into
"well, it's in main now". Sonar's "new code" Quality Gate, the
GCC
-Wall/-Werrorsplit, and clippy'swarnvs.denylint levels all encode the same insight: a tier between clean and broken is where teams actually catch drift.
The two tiers
The pattern is two recipes wrapping the same checker, plus two recipes for refreshing the baseline at each tier.
| Target | Tier | Thresholds | Baseline-filtered | Use case |
|---|---|---|---|---|
self-scan | hard | 100% of config | yes | Mirror of CI. Must stay green on every commit. |
self-scan-headroom | soft | config × HEADROOM | yes | Early-warning band. Fires before the hard tier. |
self-scan-write-baseline | hard | 100% of config | (write) | Absorb today's hard-tier offenders. |
self-scan-write-baseline-headroom | soft | config × HEADROOM | (write) | Absorb soft-tier offenders when launching or widening the band. |
The hard tier and the soft tier consume the same
[thresholds] table and the same .bca-baseline.toml. The
only difference between them is a scalar multiplier applied to
every threshold value before bca check sees it.
Write the shared baseline at the soft tier
(self-scan-write-baseline-headroom). A v5 baseline records the tier
and headroom it was written against in a [provenance] table, and
bca check warns when the current run is stricter than the baseline
was written for. A soft-0.95 baseline is a superset of the hard
gate's offenders, so the hard self-scan reads it silently; writing
the baseline at the hard tier instead would make the soft
self-scan-headroom warn that it is the stricter gate. See
Tier/headroom provenance.
This matters: it means a contributor who wants the soft tier to be stricter (catch encroachment further out) bumps a single environment variable rather than maintaining a parallel soft-threshold file that will drift out of sync with the hard config the first time anyone forgets to update both files.
Two-tier thresholds
bca check --tier <hard|soft|soft=RATIO> selects which tier to gate
against. hard (the default) compares against [thresholds] verbatim.
soft is the early-warning tier, resolved in this order:
- Start from
[thresholds](manifest, merged with--config). - If a
[thresholds.soft]table exists, merge its overrides on top. Metrics absent from the soft table inherit their hard limit (no soft band). When a soft table is present, the blanketRATIOdoes not apply — explicit per-metric limits win over the scalar. - Otherwise scale every limit by the soft
RATIO(default0.95for a baresoft, so--tier=softis never a silent no-op;soft=1.0disables scaling). - Repeated
--threshold name=valueflags apply last, absolutely.
A bare --tier=soft (ratio 0.95) is the zero-config entry point. A
[thresholds.soft] table is the surface a mature project grows into,
because it expresses a different soft band per metric — and keeps that
band recorded next to the hard limit instead of buried in a runtime
multiplier:
[thresholds]
cognitive = 25
cyclomatic = 15
nargs = 7
[thresholds.soft]
cognitive = 22 # absolute soft limit
cyclomatic = "0.9x" # 90% of the hard limit → 13.5
# nargs absent → soft tier inherits the hard limit (no soft band)
Soft limits with integer types read more cleanly as absolute
values than as float-scaled ones: prefer nargs = 6 (for a hard
nargs = 7) over the 0.95 × 7 = 6.65 a scalar would produce. Use
the "<ratio>x" form for the large-valued metrics (halstead.*,
loc.*) where an exact integer soft limit is fussy to pick. The
scale factor must be in (0, 1] — the soft tier is an early-warning
band that fires before the hard gate, never looser than it.
Both tiers ratchet through the same .bca-baseline.toml (no
separate soft baseline file). bca check --print-effective-config --tier=soft prints the resolved limits — paste its [thresholds]
output into [thresholds.soft] to migrate from a blanket-ratio band
to explicit per-metric limits.
Zero-config: the bca.toml manifest
Rather than thread --paths, --exclude-from, --jobs,
--config, --baseline, and --tier=soft=<ratio> through every
recipe, drop a bca.toml at the repo root and let bca check
discover it:
# bca.toml — discovered automatically at (or above) the working dir.
paths = ["."]
exclude_from = ".bcaignore"
jobs = "auto" # or an integer (was `num_jobs`)
[check]
baseline = ".bca-baseline.toml"
[thresholds]
cognitive = 25
cyclomatic = 15
"halstead.effort" = 50000
nom = 30
nargs = 7
nexits = 5
abc = 50
wmc = 60
The
headroomkey is the soft-tier scale ratio: it only takes effect under--tier=soft, so a barebca check(hard tier) stays the exact CI mirror regardless of anyheadroomkey. For per-metric soft limits, prefer a[thresholds.soft]table (below) over the scalar — it records the band you chose next to the hard limit instead of leaving it to a runtime multiplier.
With that file in place the four recipes collapse to one flag each:
.PHONY: self-scan self-scan-headroom \
self-scan-write-baseline self-scan-write-baseline-headroom
self-scan: # hard tier (CI mirror)
bca check
self-scan-headroom: # soft tier (early warning)
bca check --tier=soft=0.95
self-scan-write-baseline: # absorb hard-tier offenders
bca check --write-baseline
self-scan-write-baseline-headroom: # absorb soft-tier offenders
bca check --tier=soft=0.95 --write-baseline
Discovery and precedence
bcaclimbs from the working directory to the repo root (the directory containing.git) looking forbca.toml; the first match wins. Relative paths inside the manifest resolve against the manifest's own directory, so abca.tomlabove the current directory still points at the right files.- Scalars and positive scope keys: CLI wins. Any explicit
--baseline,--tier,--jobs, etc. overrides the corresponding manifest key, and the positive scope list keys (paths,include) are replaced by any explicit CLI value (bca check one.rswith manifestpaths = ["src"]checks justone.rs).--config <file>merges on top of the manifest[thresholds]table (config keys win on collision), and repeated--threshold name=valueflags apply last as absolute limits. The full resolution order —[thresholds]→--config→ tier resolution ([thresholds.soft]or the softRATIOscaling, only under--tier=soft) →--thresholdoverrides — is shared across all of--config/--tier/ the manifest. - Negative filter keys: CLI unions with the manifest. The
exclude list keys (top-level
exclude,[check] exclude) are merged, not replaced: a CLI--exclude/--check-excludeis added to the manifest's deny-set. This way a command-line filter can never silently un-exclude a directory the project config deliberately skipped (e.g.vendor/). Duplicates across the two sources collapse; CLI patterns sort first. This mirrors ruff/ESLint'sexclude(replace) vsextend-exclude(add), generalized: targets replace, filters add. As always,--xand--x-fromunion with each other regardless. Reach for--no-configwhen you need the manifest excludes gone entirely. --no-configskips discovery entirely, for reproducible fully-explicit invocations that must not pick up repo-level config.bca initalso ignores any existing manifest — it scaffolds config rather than consuming it.- The top-level
include/excludekeys are the global file-filter globs (the--include/--excludeflags) that decide which files are analysed at all. They are distinct from the[check] excludetable (analysed-and-reported but ungated paths; see Exempting whole file categories). - A
[check]table sets gate-only options.excludeis a glob list whose matching files are analysed and reported but exempt from the threshold gate (and from--write-baseline);exclude_frompoints at a.gitignore-style file of the same globs (both mirror the--check-exclude/--check-exclude-fromflags).exit_codes = "tiered"opts into the finer-grained exit codes (mirrors--exit-codes=tiered; see Exit codes);"default"(the implicit value) keeps the stable0/1/2contract. The baseline and headroom keys are gate-only too, so they live here:baseline(the filebca checkreads and a bare--write-baselinewrites),baseline_line_tolerance,baseline_fuzzy_match, andheadroom(the soft-tier scale ratio; mirrors--tier=soft=<R>). The CLI value overrides the table value for each, in either direction. - These four keys used to sit at the top level; that spelling is
deprecated (#599) and prints a one-time warning. It is honoured
for one release cycle, then removed in the next major version. Move
baseline,baseline_line_tolerance,baseline_fuzzy_match, andheadroomunder[check]. When a key is set in both places, the[check]value wins. - A
[vcs]table sets change-history ranking options forbca vcs. Itsfile_typeskey ("metrics"— the default — /"all"/ a"rs,py"-style extension list) scopes which files are ranked; as a positive scope key, an explicit--file-typesCLI flag replaces it (seebca vcsfile-type scope). cyclomatic_count_tryandexclude_testsare walker-tuning bools that mirror the--cyclomatic-count-try/--exclude-testsflags.exclude_tests = trueprunes Rust inline-test subtrees (#[test],#[cfg(test)], …) before metric computation. Both are Rust-only and inert for other grammars.--exclude-testsis presence-only (no=falseform), so its manifest key can only turn pruning on — a CLI--exclude-testswins, but the manifest cannot turn off a key the CLI did not set.- A
[thresholds.soft]table sets per-metric soft-tier limits (consumed by--tier=soft; see Two-tier thresholds). Unrecognized keys are ignored with a one-line warning, so you can pre-adopt forthcoming schema additions without breaking olderbcabuilds. bca check --print-effective-configprints the resolved view, including amanifestprovenance line, so you can see exactly what the merge produced.
The explicit-flag skeletons below remain fully supported — the manifest is sugar over the same flags, not a replacement. Reach for them when you can't drop a file at the repo root, or when one CI job needs a different layout than the committed manifest (pair the flags with
--no-config).
Skeleton: GNU Make (explicit flags)
The four recipes below are a self-contained drop-in that thread every
flag explicitly — the long form of the manifest recipe above. Adjust
the BCA variable to point at whatever invocation gives you the
checker (a pinned release binary, cargo run --release, an npm /
pip wrapper). Adjust PATHS and EXCLUDE_FROM to match your
layout.
# --- bca local threshold gates ------------------------------------------
# HARD tier mirrors CI exactly. Both tiers consume the same
# thresholds.toml + .bca-baseline.toml; the soft tier scales every
# threshold by $(BCA_HEADROOM) (default 0.95).
#
# Knobs are namespaced with `BCA_` so they don't collide with anything
# else in your environment. The big-code-analysis repo itself uses the
# manifest form above (a single `bca.toml`) rather than these explicit
# flags; reach for this skeleton when you can't drop a manifest at the
# repo root and must point `--config` at a standalone threshold file.
BCA := bca
BCA_PATHS := .
BCA_EXCLUDE_FROM := .bcaignore
BCA_THRESHOLDS := thresholds.toml
BCA_BASELINE := .bca-baseline.toml
BCA_HEADROOM ?= 0.95
# Common args, factored out so the four recipes stay in lockstep.
# `--jobs` defaults to the OS-reported effective CPU count
# (cgroup-/cpuset-aware on Linux), so no `$(nproc)` plumbing is
# needed. Override with `--jobs N` (or `--jobs 1` to force
# serial mode for debugging).
BCA_BASE_ARGS := --paths $(BCA_PATHS) --exclude-from $(BCA_EXCLUDE_FROM)
.PHONY: self-scan self-scan-headroom \
self-scan-write-baseline self-scan-write-baseline-headroom
self-scan:
@echo "bca self-scan (hard gate)..."
@$(BCA) check $(BCA_BASE_ARGS) \
--config $(BCA_THRESHOLDS) \
--baseline $(BCA_BASELINE)
# `self-scan-headroom: self-scan` is intentional: under `make -j` Make
# would otherwise run both gates in parallel and the soft tier's scaled
# error message could land before the true regression on the hard tier.
# `--tier=soft=$(BCA_HEADROOM)` scales every config limit before the
# offender comparison — no helper script, no second TOML file.
self-scan-headroom: self-scan
@echo "bca self-scan (soft gate, BCA_HEADROOM=$(BCA_HEADROOM))..."
@$(BCA) check $(BCA_BASE_ARGS) \
--config $(BCA_THRESHOLDS) \
--tier=soft=$(BCA_HEADROOM) \
--baseline $(BCA_BASELINE)
self-scan-write-baseline:
@echo "Refreshing $(BCA_BASELINE) at hard thresholds..."
@$(BCA) check $(BCA_BASE_ARGS) \
--config $(BCA_THRESHOLDS) \
--write-baseline $(BCA_BASELINE)
# Soft-tier baseline write. NOTE: this and `self-scan-write-baseline`
# both write `$(BCA_BASELINE)`; never compose them as parallel
# prerequisites of one umbrella target or invoke them with `make -j2`,
# or the two `bca` processes will race on the same file and the
# losing tier's offenders will silently vanish from the baseline.
# Run them sequentially (hard first, then soft) and commit the diff.
self-scan-write-baseline-headroom:
@echo "Refreshing $(BCA_BASELINE) at soft thresholds (BCA_HEADROOM=$(BCA_HEADROOM))..."
@$(BCA) check $(BCA_BASE_ARGS) \
--config $(BCA_THRESHOLDS) \
--tier=soft=$(BCA_HEADROOM) \
--write-baseline $(BCA_BASELINE)
bca check --tier=soft=<ratio> scales every limit from --config
by the ratio (default 0.95 for a bare --tier=soft) before the
offender comparison, then filters against the same
.bca-baseline.toml the hard tier writes.
Explicit --threshold name=value overrides are absolute and are
not rescaled. There is no separate helper script or second TOML
file to maintain — the soft tier is the hard-tier invocation plus
one flag.
Exit codes
The gate exit codes propagate verbatim from bca check: 0
clean, 2 on any threshold violation (hard or soft), 1 on tool
error. The soft tier is a real gate — never wrap
make self-scan-headroom in || true thinking it's advisory; the
non-zero exit is the whole point of the encroachment band.
Pass --exit-codes=tiered (or set [check] exit_codes = "tiered")
to split the single violation code 2 by severity: 2 new
offenders only, 3 regressions only, 4 both, 5 a --tier=soft
violation that also breaches the hard limit. The tiered codes are
opt-in; the default stays 0/1/2, and every fail-state remains
non-zero. Use them when CI needs to route "a new offender appeared"
differently from "a baselined offender got worse" without parsing
the [new] / [regr +N%] stderr tags.
Wiring into pre-commit and CI
Add the soft gate to whatever umbrella target your developers
already run before pushing. The hard gate runs as its prerequisite
(see the self-scan-headroom: self-scan edge above), so listing
only the soft target is enough — and crucially survives
make -j, which would otherwise schedule both leaves in parallel
and interleave their output:
.PHONY: pre-commit
pre-commit: fmt-check clippy test self-scan-headroom
Ordering matters: the hard tier names a true regression with the 100% limit, not the scaled one. The prerequisite edge enforces that order even under parallel Make.
In CI, run only the hard tier:
- name: Threshold gate
run: make self-scan
The soft tier is a developer feedback knob, not a release gate. Running it in CI either duplicates the hard tier (when nothing has encroached) or fires noisily on a baseline-absorbed offender that crept upward without crossing 100% — neither buys you anything CI doesn't already cover.
The headroom knob
BCA_HEADROOM is a single scalar in (0, 1]. The interesting band
is narrow:
BCA_HEADROOM | Fires when a function reaches… | Use case |
|---|---|---|
0.99 | 99% of any limit | Tightest possible warning, fires on the last commit before the hard gate would. |
0.95 | 95% of any limit (default) | One-or-two-commit lead time. Good default. |
0.90 | 90% of any limit | Wider band — useful immediately after raising a limit, while the new ceiling settles. |
1.00 | 100% (parity with hard gate) | Sanity check that the two tiers agree. |
Values below ~0.80 turn the soft tier into a second hard tier with arbitrary numbers and stop being useful: every threshold has some function near 80% of it on a real codebase, and the soft tier becomes a permanent baseline-management chore rather than an early-warning signal.
When the soft tier fires
A failed soft gate is a decision point, not a bug report. There are exactly three legitimate resolutions:
- Refactor. Same workflow as any other complexity regression — extract a helper, collapse a dispatch arm, split the function. This is the common case, and the soft tier exists to give you the time to do it on the same branch.
- Raise the limit. Edit the
[thresholds]table (inbca.tomlfor this repo, or your own threshold file), leave a why-comment explaining what changed (a new language module, a genuine algorithmic floor, a re-classified macro). Re-runmake self-scan-headroomto confirm the new value covers the offender with room to spare. - Absorb into the baseline. Run
make self-scan-write-baseline(hard tier) ormake self-scan-write-baseline-headroom(soft tier) when the value is legitimate forever — a parser dispatch arm whose width matches the grammar it covers, a stable state machine, generated code. Commit the diff in.bca-baseline.tomlin the same PR as the code that produced it.
Don't pick "raise the limit" silently to make the gate go away. The committed why-comment is the only audit trail the next reader has; without it the bumped limit looks indistinguishable from neglect.
Skeleton: justfile
For projects that prefer just:
# bca local threshold gates. Hard tier mirrors CI; soft tier (headroom)
# is local-only early warning.
bca := "bca"
paths := "."
exclude := ".bcaignore"
thresholds := "thresholds.toml"
baseline := ".bca-baseline.toml"
headroom := env_var_or_default("BCA_HEADROOM", "0.95")
# `--jobs` defaults to the effective CPU count, so the skeleton
# no longer threads `$(nproc)` through `just`. Override
# inline if needed: `just self-scan --jobs 1`.
base_args := "--paths " + paths + " --exclude-from " + exclude
self-scan:
{{bca}} check {{base_args}} \
--config {{thresholds}} --baseline {{baseline}}
self-scan-headroom: self-scan
{{bca}} check {{base_args}} \
--config {{thresholds}} --tier=soft={{headroom}} --baseline {{baseline}}
self-scan-write-baseline:
{{bca}} check {{base_args}} \
--config {{thresholds}} --write-baseline {{baseline}}
# Like the Make skeleton, never compose this with `self-scan-write-baseline`
# in parallel — they race on the same {{baseline}} file.
self-scan-write-baseline-headroom:
{{bca}} check {{base_args}} \
--config {{thresholds}} --tier=soft={{headroom}} --write-baseline {{baseline}}
Skeleton: package.json scripts
For JavaScript projects pulling in bca via npx or a pinned
binary. --jobs defaults to the effective CPU count
(cgroup-/cpuset-aware on Linux), so the npm tier no longer needs a
BCA_NUM_JOBS env var to produce byte-identical bca check
invocations as Make / just. Pass --jobs 1 explicitly only
when debugging:
{
"scripts": {
"self-scan": "bca check --paths . --exclude-from .bcaignore --config thresholds.toml --baseline .bca-baseline.toml",
"self-scan-headroom": "bca check --paths . --exclude-from .bcaignore --config thresholds.toml --tier=soft=0.95 --baseline .bca-baseline.toml",
"self-scan-write-baseline": "bca check --paths . --exclude-from .bcaignore --config thresholds.toml --write-baseline .bca-baseline.toml",
"self-scan-write-baseline-headroom": "bca check --paths . --exclude-from .bcaignore --config thresholds.toml --tier=soft=0.95 --write-baseline .bca-baseline.toml"
}
}
Because the soft tier is now a plain bca check invocation, the npm
scripts are byte-identical across shells — no helper script, no
python3-vs-py alias to paper over, no env-var-vs-shell-expansion
portability traps. To widen the band, edit the literal 0.95 in the
script (or wire it through your task runner of choice); the flag
parses the same on every platform.
Pair with husky or
pre-commit so the same scripts run on
git commit.
Skeleton: pre-commit hook
If you use the pre-commit framework
(version 3.2.0 or newer — see the version note below), both
tiers are local hooks that shell out to make:
- repo: local
hooks:
- id: bca-self-scan
name: bca self-scan (hard gate)
entry: make self-scan
language: system
pass_filenames: false
stages: [pre-commit]
- id: bca-self-scan-headroom
name: bca self-scan-headroom (soft gate)
entry: make self-scan-headroom
language: system
pass_filenames: false
stages: [pre-commit]
pass_filenames: false is deliberate — bca discovers its own
inputs from --paths plus the baseline. Letting pre-commit
pass the changed files in would shrink the scan to just those
files and miss the cross-file effect of a baseline refresh.
Minimum
pre-commitversion 3.2.0. Thestages:vocabulary was renamed in pre-commit 3.2.0 (March 2024) —commit→pre-commit,push→pre-push, etc. Older installs (notably RHEL 8 EPEL, Ubuntu 20.04 default packages, and any.pre-commit-config.yamlpinned to the legacy vocabulary) rejectstages: [pre-commit]as an unknown stage name and the hook never registers. If you must support older installations, substitutestages: [commit]; in mixed fleets, pin the framework withpre-commit --version≥ 3.2.0 in the dev-tooling docs so this contradiction does not surface silently.
Composition with the broader baseline workflow
The four self-scan* targets above are not a replacement for the
documented Baselines recipe — they are that
recipe, mechanised into developer-machine commands. The same
ordering still applies:
- Bootstrap once. Write the initial thresholds, write the initial baseline, commit both.
- Gate on every commit. Hard tier fails on regression; soft tier fails on encroachment.
- Refresh during focused refactors. When a function legitimately moved (someone did pay down debt), regenerate the baseline and review the diff.
- Retire when empty. When
.bca-baseline.tomlshrinks to justversion = 5(the bare schema stamp with no offender entries), drop the--baselineflag and delete the file. The thresholds now stand on their own.
The local tiers shorten the feedback loop on steps 2 and 3 from
"red CI on a pull request" to "red Make recipe before
git commit returns". That is the whole pitch.
Related industry patterns
The hard / soft tier split is one instance of a broader pattern. If you have used any of the following, the mental model carries over:
- Sonar's
Quality Gates focused on new code.
Old code is held at its current state; changes must not make
things worse. The baseline file is
bca's native form of the "new code" / "leak period" idea. - clippy's
warn-vs-denylint levels. Awarnlint surfaces in local builds; the same lint denied with-D warningsfails CI. The two-tier configuration gives you a place to land experimental tighter rules. - The
ratchet pattern in
general migration tooling: record today's count, fail on
increase, lower the ceiling as the count drops.
bca checkratchets per-function rather than per-pattern, but the monotonicity guarantee is the same. -Wall+-Werrorin C/C++. A first pass with-Wallreveals the noise; promoting to-Werrorafter the baseline reaches zero is the same retirement step as deleting.bca-baseline.tomlonce it's empty.