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

ベースライン: 既存コードでしきい値をラチェットする

Introducing thresholds on an existing codebase can flag violations unrelated to the current change. A baseline records accepted violations so the gate fails on new offenders and regressions, and shrinks as the team pays the recorded debt down. This recipe walks through adopting one, reading the gate output it produces, and keeping it fresh; the flag reference is on the check page.

Baselines complement in-source suppression markers; they are not a substitute. Use suppression markers when a function is intentionally complex forever (a parser, a state machine, generated code). Use a baseline when the team intends to pay the debt down. Both can live in the same repo; suppression is checked first.

エンドツーエンドの採用フロー

ワンショットのショートカット: bca init は、統合された bca.toml マニフェスト(paths、exclude_from、baseline、[thresholds] テーブルを含む)、それが参照する .bcaignore、そして現在のツリーから導出した初期 .bca-baseline.toml を 1 つのコマンドでスキャフォールドします。マニフェストが配置されていれば、引数なしの bca check がそれを自動発見し、設定なしでゲートします。既存ファイルを上書きするには --force を、ツリーの走査をスキップするには --no-baseline を渡します。以下の長めのレシピは、ベースラインをブートストラップする前にしきい値を調整したい場合に有用です。

1. 初期しきい値を決める

感覚的な数値(cyclomatic=15、cognitive=20)を使うか、リポジトリ全体に対する bca check --no-fail の実行結果から現在の分布を確認して決めます。

# bca.toml at the repo root, discovered automatically by `bca check`.
paths = ["src"]

[check]
baseline = ".bca-baseline.toml"

[thresholds]
cyclomatic = 15
cognitive = 20
"loc.lloc" = 200

2. ベースラインをブートストラップする

bca check --write-baseline

A bare --write-baseline (no path) writes to the baseline key from the bca.toml you just created, so the filename lives in exactly one place. Pass an explicit path (--write-baseline <file>) only when you have no manifest baseline to default to; without one, the bare form errors rather than guessing a filename.

両方のファイルを同じ変更でコミットします:

git add bca.toml .bca-baseline.toml
git commit -m "ci: introduce metric thresholds with baseline"

Path keys in the baseline are stored relative to the baseline file’s own directory (the anchor). --paths ., --paths src/, and --paths "$PWD" produce byte-identical baselines, and --baseline runs match whichever --paths form CI uses. Switch between them freely without re-running --write-baseline.

3. CI ゲートを組み込む

GitHub Actions:

- name: Check code complexity thresholds
  run: |
    bca check
  # `paths`、しきい値、`baseline` はすべてリポジトリルートで
  # 自動発見された `bca.toml` マニフェストから取得されます。

GitLab CI/CD (job excerpt):

threshold-check:
  image: rust:1
  before_script:
    - cargo install --locked big-code-analysis-cli@<VERSION>
  script:
    - bca check

終了コード: 0 はクリーン、2 はリグレッションまたは新規違反、1 はツールエラーです。CI 環境ごとの幅広い対応表については CI 統合 を参照してください。

4. チームの負債返済に合わせてベースラインを更新する

数週間ごと、または集中的なリファクタリングの後に:

cp .bca-baseline.toml .bca-baseline.old.toml
bca check --write-baseline
bca diff-baseline .bca-baseline.old.toml .bca-baseline.toml

diff が縮小していくことが目標です。変更のないツリーに対して --write-baseline を 2 回実行するとバイト単位で同一の出力が生成されるため、余計な diff が現れるのは実際の違反箇所が変わったときだけです。

You do not have to guess when a refresh is due. A gated run whose offenders have measured better than their recorded values says so on stderr (see the stale-entry warning), because the difference between recorded and live is suppression nobody chose.

制限を引き締める前に、両方の層でコストを見積もる

Paying debt down invites tightening the limit that produced it, and the obvious measurement, bca check --threshold <metric>=<candidate>, answers only half the question. A --threshold value is applied last and absolutely, never scaled, so it has no soft tier. A candidate that costs nothing at the hard gate can still put a whole population permanently inside the --tier=soft band, and you will not see it until you have edited bca.toml and run the other gate.

bca check --explain-threshold cognitive=15

Weigh the new column, not the offender count: it is how many baseline entries the change would add, and it is what a reviewer is being asked to approve. A cluster: line means the candidate landed on top of an existing population, and those entries can never be retired; see Tightening a limit onto a cluster.

5. PR レビューのヒューリスティクス

Run bca diff-baseline <old> <new> and read the summary instead of parsing a raw git diff .bca-baseline.toml in your head. It pairs entries on their (path, qualified, metric) identity, so a function that merely drifted up or down the file is not reported as a remove + add, and buckets every real change:

1 added, 1 removed, 2 worsened, 0 improved

## Added
  src/new.rs::shiny        cognitive  = 30

## Removed
  src/gone.rs::old_fn      nargs      = 9

## Worsened
  src/bar.rs::act_on_file  cognitive  60 → 63
  src/foo.rs::do_thing     cognitive  25 → 27

各バケットを従来のヒューリスティクスに対応付けると:

  • removed(ベースラインが縮小)。 負債が返済されました。追加の対応は不要です。
  • added (baseline grew). Someone added a new offender to the file intentionally. Review the values: was this a deliberate stopgap, or did the author bypass the gate? Either is fine if conscious; the point of committing the file is to make the choice reviewable.
  • worsened (an entry got a higher value). The author re-ran --write-baseline after the function got worse. Treat it the same as added and surface the change in review.
  • improved. A recorded offender got better without dropping out of the baseline. That is a good sign the refactor is working, and the reason to land the refreshed file rather than leave it. Until it is refreshed, the old entry keeps suppressing the offender up to a value the tree no longer produces.

For a PR bot, bca diff-baseline <old> <new> --format markdown emits a fenced block ready to drop into a sticky comment, and the --worsened-only / --added-only filters narrow it to just the regressions reviewers must look at. --format json feeds the same diff to other tooling. The command exits 0 by default, because it informs review rather than gating (the gate is bca check itself); the opt-in --exit-code flag exits 2 on a non-empty filtered diff.

ゲート出力の読み方

bca check --baseline の実行が失敗すると、残った各違反にタグのプレフィックスが付き、リストの後にファイルごとのロールアップが続きます:

bca: filtered 422 violations via baseline
[regr +60%] src/foo.rs:1-865: <file>: halstead.effort = 1557107.72 (limit 50000)
[new] src/bar.rs:506-747: act_on_file: cognitive = 63 (limit 25)
...

--- summary ---
src/foo.rs: 5 violations (worst: halstead.effort = 1557107.72 vs limit 50000 at L1)
src/bar.rs: 4 violations (worst: cognitive = 63 vs limit 25 at L506)

タグのプレフィックス:

  • [new]: no baseline entry matched this violation by qualified symbol (within the line tolerance) or, when --baseline-fuzzy-match is set, by body hash. The violation is new since the baseline was written. See Matching for the resolution order.

  • [regr …]: the baseline contains a recorded value and the current value is worse than it. “Worse” is measured in the metric’s own direction, so for the lower-is-worse mi.* family a drop below the recorded value reads + like any other regression. The amount takes one of two shapes, both starting [regr , so rg '\[regr ' matches every regressed row:

    • [regr +N%] for a count (every metric outside mi.*): N is the rise as a percentage of the recorded value, rounded to an integer.

    • [regr +N.N pts] for mi.original, mi.sei, and mi.visual_studio: N.N is the drop in index points, to one decimal. The Maintainability Index has an arbitrary zero and mi.original and mi.sei go negative on large functions, so a percentage of the record means nothing: a drop from −10 to −20 reads [regr +10.0 pts], and a 0.01-point drop from a record of 0.001 reads [regr +0.0 pts] rather than +1069%. Special forms:

    • [regr from 0] when a count’s recorded value is 0.0 and a percentage would divide by zero. An mi.* record of zero is an ordinary point and reads in points.

    • [regr +>9999%] caps once a count’s regression exceeds 100× the baseline value. Points are not capped.

    • [regr NaN] — 現在のメトリクス値が NaN の場合(自明な関数における退化した Halstead 入力)。

タグは --baseline を渡したときにのみ表示されます。渡さない場合、行のフォーマットはベースラインなしのデフォルトとバイト単位で同一です。統合されたストリームを読む CI ツールは、--no-summary で末尾のサマリーを抑制できます。

The summary footer groups violations by file, cites the single worst metric per file (by severity: value / limit for a count, one step per maintainability band below the floor for mi.*), and sorts rows by violation count descending then path ascending. Use the footer to identify files with the most violations before reading individual offender rows.

2 つのブランチのマージ

ベースラインは丸ごと生成されるものなので、双方が触れた 2 つのブランチの「テキスト上の」マージが正しくなることはありません。各側の値はそれぞれ自分のツリーに対して測定されたものであり、マージ後のツリーはどちらにも一致しません。.gitattributes の 1 行で、git に試みるなと伝えてください:

.bca-baseline.toml -merge

Git then leaves the file conflicted as a whole instead of splicing the two sides together, and the resolution is always the same: regenerate.

bca check --write-baseline
git add .bca-baseline.toml

merge=ours ドライバーと異なり、-merge はクローンごとの git config を必要としないため、設定方法をすでに知っていた人だけでなく、リポジトリをクローンする全員に対して機能します。

6. ベースラインを退役させる

.bca-baseline.toml が version = 6 のみでエントリを含まなくなったら、CI から --baseline フラグを外してファイルを削除します。以降はしきい値だけで成立します。

ティア/ヘッドルームの来歴

--write-baseline(v5 以降)で書き込まれたベースラインは、「どのゲートに対して書き込まれたか」を [provenance] テーブルに記録します:

version = 6

[provenance]
tier = "soft"
headroom = 0.95
mi_charges_docs = false
mi_formula = 2
  • tier = "hard": written by the hard gate (bca check --write-baseline …); no headroom key.
  • tier = "soft", headroom = <ratio>: written by the soft gate scaled by the soft ratio (bca check --tier=soft=0.95 --write-baseline …).
  • tier = "soft" with no headroom: written by the soft gate driven by a [thresholds.soft] table (per-metric limits, no single ratio).
  • mi_charges_docs: whether the maintainability index charged documentation rows when the file was written (the --mi-charges-docs flag or manifest key; see Charging documentation). Absent from a file written before the key existed, which reads as true: every earlier bca charged documentation.
  • mi_formula: the revision of the maintainability-index formula the file’s mi.* values were computed under (2 since the size term moved to PLOC and mi.sei to a 0–1 comment ratio). Absent from a file written before the key existed, which reads as revision 1.

この来歴はコメントではなく本物の TOML テーブルなので、bca diff-baseline や外部ツールが読み取れます。古い bca(v2–v4)で書き込まれたベースラインは来歴を持ちませんが、エラーなく読み込まれます。

ベースラインより厳しい場合の警告

bca check は、ベースラインの来歴と現在の実行の実効制限を単一の「厳格度」スカラーに縮約し(ハード → 1.0、h でスケールされたソフト → h、小さいほど厳しい)、現在の実行がベースライン書き込み時よりも*「厳しい」*場合に警告します:

warning: this check's effective limits (strictness 0.9) are stricter
than the baseline was written against (strictness 0.95); the baseline
may under-cover and the gate can fire on untouched files. Refresh it at
the matching tier, …

これはベースライン更新の規律が防ごうとしているサイレントな不整合です。現在のゲートより「緩く」書き込まれたベースラインは、より厳しいゲートが検出するすべての違反を網羅していない可能性があり、誰も触れていないファイルで突然ゲートが発火することがあります。

The warning is directional. It fires only when the current run is stricter. It stays silent in the safe direction: a hard check (strictness 1.0) reading a soft-0.95 baseline sees a superset of its offenders, which is the intended single-baseline setup where make self-scan (hard) and make self-scan-headroom (soft) ratchet through the same .bca-baseline.toml. It also stays silent for equal provenance, for pre-v5 baselines (provenance unknown), and when either side is a [thresholds.soft]-table baseline (no single ratio to compare). To clear a real warning, refresh the baseline at the current tier with the matching --write-baseline recipe.

The MI formula-revision warning

A baseline’s mi.* values are only comparable with a run that computes MI the same way. When the formula itself changes, every recorded value moves for unchanged code, so bca check warns whenever a baseline holding any mi.* entry records a different mi_formula revision from the one the run computes, in either direction:

warning: this baseline records mi.* values under MI formula revision 1;
this run computes revision 2. The recorded values are not comparable
and can misclassify a regression. Review any mi.* threshold and refresh
with `--write-baseline <file>`.

It does not wait for an mi.* offender: it names the stale file on every read, so the record is refreshed before an mi.* gate comes to depend on it. A file written before the key existed reads as revision 1, so the first check after upgrading warns. While it fires, the documentation-mode warning below is not shown: a baseline from another formula is not worth comparing mode by mode. Refresh it with --write-baseline. bca diff-baseline warns the same way when its two files record different revisions and either holds an mi.* entry, since their mi.* deltas then reflect the formula, not the code.

The MI documentation-mode warning

The mi.sei values a baseline records depend on whether documentation rows were charged; mi.original and mi.visual_studio do not. Including documentation rows raises mi.sei. A baseline recorded with documentation included can therefore reject unchanged code when the current run excludes documentation. Reversing the modes can hide a regression. bca check warns in either direction:

warning: this check computes mi.* leaving documentation out
(mi_charges_docs = false) but the baseline was written charging
documentation (mi_charges_docs = true); its mi.sei entries describe a
different comment share (mi.original and mi.visual_studio read the
same in both modes), …

A baseline that records no mode was written by a bca that charged documentation, so it reads as mi_charges_docs = true. Every such file a release wrote also predates the formula stamp, so after upgrading the formula-revision warning above fires instead. The documentation-mode warning stays silent when the modes agree and when no mi.sei value is read against the baseline: a baseline holding no mi.sei entry, or a run with no mi.sei offender, is unaffected by the mode. Clear it by refreshing under the current mode, or by passing the --mi-charges-docs=<bool> the message names to match the baseline.

The stale-entry warning

The ratchet suppresses a violation for as long as it has not worsened past its recorded value, so everything on the improving side classifies alike: a function measuring 5 against a recorded 7 is treated exactly like one still measuring 7. The two points between them are gate headroom nobody chose; the function can grow back to 7 and the gate will not notice.

A gated run reports that drift in one stderr line:

warning: 3 baseline entries improved past the recorded value (worst:
src/spaces/compute.rs::metrics_inner halstead.effort 119147.75 →
116715.61); that gap is gate headroom nobody chose, so refresh with
`--write-baseline`. …

It names one example rather than every entry, because the response to any number of them is the same wholesale refresh. Direction follows the metric: for the lower-is-worse mi.* family the stale direction is a rise above the record. An entry sitting exactly on its recorded value is silent, so a freshly written baseline never warns about itself.

It finds only half of the staleness. A function that stopped breaching its limit altogether produces no violation at all, so it never reaches the baseline matcher and cannot be counted here. Its entry stays in the file, inert and invisible, until someone regenerates.

This repository closes the other half with a scheduled job rather than a gate, and the shape is worth copying. baseline-freshness.yml runs quarterly. It regenerates the baseline with the same --write-baseline recipe that wrote the committed one (the job pins that recipe, so a tier change has to land in both places), diffs the two with bca diff-baseline --exit-code, and files a labelled issue naming what moved. Two details of the job carry its design:

  • It uses diff-baseline rather than git diff, because git diff reports a moved start_line as staleness.
  • It looks up an existing issue by label and title before filing, because a stale baseline persists until someone commits a refresh, so an unconditional file would open one issue per quarter for the same entry. The label alone is shared with other findings; the fixed title is what keeps the comment on the right issue.

Nothing is gated on the result: the tree is fine, the file describing it has aged.

マッチングの仕組み

Each entry is keyed on (path, qualified_symbol, metric). The qualified symbol is the ::-joined chain of enclosing named containers plus the function name (MyStruct::do_thing, my_namespace::MyClass::method). The top-level file space collapses to <file>. A violation is resolved against the baseline in this order:

  1. Qualified symbol. If exactly one entry shares the violation’s (path, qualified_symbol, metric), it matches regardless of line number, so editing code above a function does not re-key it as [new].

  2. Start-line tolerance. If several entries share that key (two methods named is_valid on different impl blocks the analyzer could not tell apart, overloads, …), the entry whose recorded start_line is closest to the violation, and within --baseline-line-tolerance lines (default 50), wins. Beyond the tolerance the violation is [new].

    This is the only rule that reads a line number, so from schema v6 a start_line is written only for entries in such a group. Everywhere else the field is absent, and the file therefore does not change when an edit above a baselined function moves it. That is what keeps a real baseline change (a value moving) visible in a diff instead of buried in line-number noise.

  3. ボディハッシュ(オプトイン)。 --baseline-fuzzy-match を指定すると、修飾シンボルがもはや一致しない違反は、同じ (path, metric) 内で正規化されたボディハッシュが同一のエントリと照合されます。これにより、関数の形を保ったままのリネームを吸収できます(ダイジェストは関数自身の名前を除外し、インデント・空行・CRLF の影響を受けません)。ハッシュは --baseline-fuzzy-match が設定されている場合にのみベースラインに書き込まれるため、ファジー読み取りを有効にするには一度ファジーな --write-baseline でシードしてください。両方のキーは bca.toml の [check] の下に baseline_line_tolerance および baseline_fuzzy_match として設定します(トップレベルに直接書く綴りは非推奨で警告が出ます。#599 を参照)。

Anonymous functions (closures, lambdas) have no stable name, so their qualified symbol bakes in the line (outer::<anon@L42>). They therefore re-key as [new] when they move. Symbol matching survives line drift only for named top-level and method-bound functions, which produce the bulk of baseline churn.

修復フッター

When the gate finds violations, bca check emits a trailing --- next steps --- block on stderr (the offender rows themselves go to stdout) and inside the $GITHUB_STEP_SUMMARY digest. The block names the artifact, prints a copy-paste-safe --write-baseline refresh invocation, and links back to this recipe. The refresh invocation mirrors the gate’s resolved --paths / --exclude / --exclude-from / --config / --baseline arguments, so a first-time reader of a failing CI log can refresh the baseline without leaving the page.

下流のツールが統合されたストリームを読んでいて末尾のブロックが混乱を招く場合は、--no-remediation でブロックを抑制します。

抑制マーカーとの組み合わせ

--write-baseline already excludes any function silenced by a bca: suppress or #lizard forgives marker, so the same function does not end up in two places. If a function is intentionally exempt forever, prefer the in-source marker: it lives next to the code, survives refactors, and needs no extra file to commit. Use the baseline only for violations the team intends to fix.

To audit the un-filtered offender set (every violation regardless of suppression or baseline), pass --no-suppress and omit --baseline:

bca check --paths src/ \
    --no-suppress \
    --no-fail

--write-baseline と組み合わせると、--no-suppress は抑制マーカーが通常隠す違反も含め、すべての違反を記録します。

すべての除外を一度に監査する

ベースラインは、コードがゲートを逃れる 3 つの方法のうちの 1 つです。残りの 2 つは、ソース内の bca: suppress マーカーと [check.exclude] グロブです。bca exemptions はこの 3 層すべてを 1 つのレポートに列挙するため、レビュアーは 3 つのコマンドを実行することなく、bca check がスキップしているものを一覧できます:

bca exemptions --paths src/
# In-source markers (2)
  src/parser.rs:120  bca: suppress       metrics=all  parse_long
  ...

# [check.exclude] globs (1)
  tests/**

# Baseline (.bca-baseline.toml, 417 entries)
  src/markdown_report.rs write_language_section cognitive 29
  ...

ベースラインのセクションは、bca check と同じ --baseline / bca.toml の [check] baseline ソース(またはデフォルトの .bca-baseline.toml)を読み取ります。ベースライン化された違反だけを列挙するには --baseline-only を、PR コメント用には --format markdown を、ダッシュボード用には --format json を使います。PR レビュー時には、前述の bca diff-baseline <old> <new> と組み合わせてください。diff はベースラインで何が「変わった」かを示し、bca exemptions は現在の除外面の全体を示します。完全なフラグリファレンスは抑制マーカーのページを参照してください。

制限事項

  • あいまいなシンボル。 2 つの関数が修飾シンボルを共有し(アナライザが異なるコンテナを解決できなかった、または言語がオーバーロードを許している)、かつ両方が記録された行から --baseline-line-tolerance を超えてずれている場合、どちらも判別できず、違反は [new] として現れます。--write-baseline で更新するか、トレランスを引き上げてください。
  • 匿名関数。 クロージャとラムダは、その合成シンボルに行番号が埋め込まれているため、移動すると再キー化されます(マッチングの仕組みを参照)。
  • OS portability. Paths are normalized to forward slashes on write and re-normalized on read, so a baseline generated on Linux matches the same tree on Windows. On Unix and Windows the path key is percent-encoded, so a non-UTF-8 path keeps its identity rather than collapsing onto a lossy display form; the backslash-to-slash normalization is the one deliberate exception.
  • Tightening a threshold. Lowering a limit may newly expose functions that were previously clean. They will not be in the baseline, so CI will fail. This is correct: tightening should expose new offenders. Refresh the baseline if the team chooses to absorb the new entries.