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.

  1. 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.
  2. 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.
  3. 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 / -Werror split, and clippy's warn vs. deny lint 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.

TargetTierThresholdsBaseline-filteredUse case
self-scanhard100% of configyesMirror of CI. Must stay green on every commit.
self-scan-headroomsoftconfig × HEADROOMyesEarly-warning band. Fires before the hard tier.
self-scan-write-baselinehard100% of config(write)Absorb today's hard-tier offenders.
self-scan-write-baseline-headroomsoftconfig × 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:

  1. Start from [thresholds] (manifest, merged with --config).
  2. 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 blanket RATIO does not apply — explicit per-metric limits win over the scalar.
  3. Otherwise scale every limit by the soft RATIO (default 0.95 for a bare soft, so --tier=soft is never a silent no-op; soft=1.0 disables scaling).
  4. Repeated --threshold name=value flags 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 headroom key is the soft-tier scale ratio: it only takes effect under --tier=soft, so a bare bca check (hard tier) stays the exact CI mirror regardless of any headroom key. 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

  • bca climbs from the working directory to the repo root (the directory containing .git) looking for bca.toml; the first match wins. Relative paths inside the manifest resolve against the manifest's own directory, so a bca.toml above 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.rs with manifest paths = ["src"] checks just one.rs). --config <file> merges on top of the manifest [thresholds] table (config keys win on collision), and repeated --threshold name=value flags apply last as absolute limits. The full resolution order — [thresholds]--config → tier resolution ([thresholds.soft] or the soft RATIO scaling, only under --tier=soft) → --threshold overrides — 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-exclude is 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's exclude (replace) vs extend-exclude (add), generalized: targets replace, filters add. As always, --x and --x-from union with each other regardless. Reach for --no-config when you need the manifest excludes gone entirely.
  • --no-config skips discovery entirely, for reproducible fully-explicit invocations that must not pick up repo-level config. bca init also ignores any existing manifest — it scaffolds config rather than consuming it.
  • The top-level include / exclude keys are the global file-filter globs (the --include / --exclude flags) that decide which files are analysed at all. They are distinct from the [check] exclude table (analysed-and-reported but ungated paths; see Exempting whole file categories).
  • A [check] table sets gate-only options. exclude is a glob list whose matching files are analysed and reported but exempt from the threshold gate (and from --write-baseline); exclude_from points at a .gitignore-style file of the same globs (both mirror the --check-exclude / --check-exclude-from flags). exit_codes = "tiered" opts into the finer-grained exit codes (mirrors --exit-codes=tiered; see Exit codes); "default" (the implicit value) keeps the stable 0/1/2 contract. The baseline and headroom keys are gate-only too, so they live here: baseline (the file bca check reads and a bare --write-baseline writes), baseline_line_tolerance, baseline_fuzzy_match, and headroom (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, and headroom under [check]. When a key is set in both places, the [check] value wins.
  • A [vcs] table sets change-history ranking options for bca vcs. Its file_types key ("metrics" — the default — / "all" / a "rs,py"-style extension list) scopes which files are ranked; as a positive scope key, an explicit --file-types CLI flag replaces it (see bca vcs file-type scope).
  • cyclomatic_count_try and exclude_tests are walker-tuning bools that mirror the --cyclomatic-count-try / --exclude-tests flags. exclude_tests = true prunes Rust inline-test subtrees (#[test], #[cfg(test)], …) before metric computation. Both are Rust-only and inert for other grammars. --exclude-tests is presence-only (no =false form), so its manifest key can only turn pruning on — a CLI --exclude-tests wins, 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 older bca builds.
  • bca check --print-effective-config prints the resolved view, including a manifest provenance 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_HEADROOMFires when a function reaches…Use case
0.9999% of any limitTightest possible warning, fires on the last commit before the hard gate would.
0.9595% of any limit (default)One-or-two-commit lead time. Good default.
0.9090% of any limitWider band — useful immediately after raising a limit, while the new ceiling settles.
1.00100% (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:

  1. 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.
  2. Raise the limit. Edit the [thresholds] table (in bca.toml for 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-run make self-scan-headroom to confirm the new value covers the offender with room to spare.
  3. Absorb into the baseline. Run make self-scan-write-baseline (hard tier) or make 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.toml in 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-commit version 3.2.0. The stages: vocabulary was renamed in pre-commit 3.2.0 (March 2024) — commitpre-commit, pushpre-push, etc. Older installs (notably RHEL 8 EPEL, Ubuntu 20.04 default packages, and any .pre-commit-config.yaml pinned to the legacy vocabulary) reject stages: [pre-commit] as an unknown stage name and the hook never registers. If you must support older installations, substitute stages: [commit]; in mixed fleets, pin the framework with pre-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:

  1. Bootstrap once. Write the initial thresholds, write the initial baseline, commit both.
  2. Gate on every commit. Hard tier fails on regression; soft tier fails on encroachment.
  3. Refresh during focused refactors. When a function legitimately moved (someone did pay down debt), regenerate the baseline and review the diff.
  4. Retire when empty. When .bca-baseline.toml shrinks to just version = 5 (the bare schema stamp with no offender entries), drop the --baseline flag 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.

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-deny lint levels. A warn lint surfaces in local builds; the same lint denied with -D warnings fails 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 check ratchets per-function rather than per-pattern, but the monotonicity guarantee is the same.
  • -Wall + -Werror in C/C++. A first pass with -Wall reveals the noise; promoting to -Werror after the baseline reaches zero is the same retirement step as deleting .bca-baseline.toml once it's empty.