やってみた 2026年8月31日

checkstyleの終了コードは違反件数そのものだった。ちょうど256件でCIが緑になる

XECIN Java静的解析CI/CDcheckstyle

GitHubのトレンドに checkstyle/checkstyle が8番目に上がっていました。

恥ずかしながら、僕はcheckstyleを「Mavenのpom.xmlに書いてあるやつ」としてしか知りませんでした。前の現場のJavaプロジェクトにも入っていたけれど、設定を書いた記憶はないし、CIが赤くなった記憶もない。トレンドに出ていたのは前日にリリースされた 14.1.0 のタイミングだったようなので、これを機に単体で触ってみることにしました。

結果として、僕がそれまで一度も疑っていなかった「CIが緑だった」という事実のほうが、実はあまり意味を持っていなかったことが分かりました。

まずは1ファイル通してみる

検証は使い捨てのDockerコンテナ(eclipse-temurin:21-jdk)の中でやっています。中身は Ubuntu 26.04 LTS、OpenJDK は 21.0.12(Temurin-21.0.12+8-LTS)です。

READMEには「Download our Latest Release from GitHub」と書いてあって、リリースには checkstyle-14.1.0-all.jar という単一のjarがぶら下がっています。ビルド不要でこれ1本。ダウンロードは877ミリ秒、14,700,942バイトでした。

叩く対象として、わざと行儀の悪いJavaファイルを348バイトぶん用意しました。スター付きインポート、大文字始まりのメソッド名、詰めたスペース、100文字を超える行。checkstyleに同梱されている google_checks.xml と sun_checks.xml の両方に食わせてみます。

# 準備
$ curl -sLO https://github.com/checkstyle/checkstyle/releases/download/checkstyle-14.1.0/checkstyle-14.1.0-all.jar
$ java -jar checkstyle-14.1.0-all.jar --version
Checkstyle version: 14.1.0

# A. google_checks
$ java -jar checkstyle-14.1.0-all.jar -c /google_checks.xml src/Hello.java
Starting audit...
[WARN] /work/src/Hello.java:2:17: Using the '.*' form of import should be avoided - java.util.*. [AvoidStarImport]
[WARN] /work/src/Hello.java:5:15: Method name 'Greet' must be more than a character, start lowercase, ... [GoogleMethodName]
[WARN] /work/src/Hello.java:8: Line is longer than 100 characters (found 147). [LineLength]
   ... (計30件、すべて [WARN])
Audit done.
$ echo $?
0

# B. sun_checks(同じファイル)
$ java -jar checkstyle-14.1.0-all.jar -c /sun_checks.xml src/Hello.java
Starting audit...
[ERROR] /work/src/Hello.java:2:17: Using the '.*' form of import should be avoided - java.util.*. [AvoidStarImport]
[ERROR] /work/src/Hello.java:7:19: '3' is a magic number. [MagicNumber]
[ERROR] /work/src/Hello.java:8: Line is longer than 80 characters (found 147). [LineLength]
   ... (計30件、すべて [ERROR])
Audit done.
Checkstyle ends with 30 errors.   # ← これは stderr
$ echo $?
30

同じファイル、同じ30件の指摘。なのに片方は終了コード0で、もう片方は30。

理由自体は設定ファイルを開けばすぐ分かりました。jarの中から取り出した google_checks.xml は526行・モジュール108個で、23行目に <property name="severity" value="${org.checkstyle.google.severity}" default="warning"/> と書いてあります。既定が warning。いっぽう sun_checks.xml は215行・モジュール73個で、42行目が <property name="severity" value="error"/> です。

つまりgoogle_checksは「指摘はするが、それは警告である」という立場で、終了コードには一切反映されません。stderrも空のままです。標準出力に30行の [WARN] が出ているだけ。

CIのログをスクロールせず終了コードだけ見ていたら、30件の指摘は存在しないのと同じでした。前の現場でCIが赤くならなかったのは、たぶんこれです。

件数を増やしていったら、256でゼロに戻った

ここで気になったのが、sun_checksのほうの終了コード「30」です。指摘が30件で、終了コードも30。偶然にしては出来すぎている。

LineLength だけを max=20 で有効にした最小構成を書いて、長い行をN本だけ持つファイルを機械的に生成し、Nを振ってみました。1ケースにつき1プロセスです。

$ for n in 1 100 254 255 256 257 300 512; do
>   gen $n   # n本の長い行を持つ N.java を生成
>   java -jar checkstyle-14.1.0-all.jar -c only-linelength.xml n/N.java > /tmp/o.txt 2>/tmp/e.txt
>   rc=$?    # 終了コードは java の直後で受ける
>   viol=$(grep -c '^\[ERROR\]' /tmp/o.txt)
>   echo "long_lines=$n  reported_errors=$viol  exit_code=$rc  stderr=\"$(head -1 /tmp/e.txt)\""
> done
long_lines=1    reported_errors=1    exit_code=1    stderr="Checkstyle ends with 1 errors."
long_lines=100  reported_errors=100  exit_code=100  stderr="Checkstyle ends with 100 errors."
long_lines=254  reported_errors=254  exit_code=254  stderr="Checkstyle ends with 254 errors."
long_lines=255  reported_errors=255  exit_code=255  stderr="Checkstyle ends with 255 errors."
long_lines=256  reported_errors=256  exit_code=0    stderr="Checkstyle ends with 256 errors."
long_lines=257  reported_errors=257  exit_code=1    stderr="Checkstyle ends with 257 errors."
long_lines=300  reported_errors=300  exit_code=44   stderr="Checkstyle ends with 300 errors."
long_lines=512  reported_errors=512  exit_code=0    stderr="Checkstyle ends with 512 errors."

256件ちょうどのとき、終了コードが0になりました。512件でも0です。

調べてみたところ、これはcheckstyle側で何かが起きているというより、終了コードを1バイトで表現する仕組みそのものの話でした。Main.java を読むと exitStatus = execute(parseResult, cliOptions); のあと errorCounter = exitStatus; と置いて、最後に Runtime.getRuntime().exit(exitStatus) に生の件数をそのまま渡しています。300を渡せば 300 - 256 = 44 が返る。表にするとこうです。

違反件数終了コードstderr の文言CIから見えるもの
255255Checkstyle ends with 255 errors.失敗
2560Checkstyle ends with 256 errors.成功
30044Checkstyle ends with 300 errors.失敗
5120Checkstyle ends with 512 errors.成功

大事なのは、stderrのほうは全ケースで正しい件数を言っているという点です。情報が失われているわけではなくて、終了コードという1バイトの通り道だけが件数を運びきれていない。ログを読めば512件と書いてあります。

正直なところ、256件ちょうどでビルドが通る状況に実務で当たる確率はかなり低いと思います。ただ「終了コードは件数である」と知っているかどうかで、CIの書き方は変わってくるはずです。

引数を間違えても255だった

もうひとつ、ついでに試したことがあります。エラー系の終了コードです。

$ java -jar checkstyle-14.1.0-all.jar --no-such-flag src/Hello.java
Unknown option: '--no-such-flag'
$ echo $?
255

$ java -jar checkstyle-14.1.0-all.jar -c /work/does-not-exist.xml src/Hello.java
Could not find config XML file '/work/does-not-exist.xml'.
$ echo $?
255

$ java -jar checkstyle-14.1.0-all.jar -c /google_checks.xml src/NoSuchFile.java
Files to process must be specified, found 0.
$ echo $?
255

$ java -jar checkstyle-14.1.0-all.jar
Missing required parameter: '<files or folders>'
$ echo $?
255

不明なオプション、設定ファイルが見つからない、入力ファイルが見つからない、引数なし。全部255です。ソースを見ると EXIT_WITH_INVALID_USER_INPUT_CODE = -1 と定義されていて、これも同じ exit() を通ります。-1 は1バイトに落ちると255になります(例外系の -2 も同様に254になるはずですが、こちらは再現条件を作れていないので未確認です)。

そして255は「255件の違反」の終了コードでもあります。つまり終了コードだけを見ている限り、設定ファイルのパスをtypoして一切検査されなかったケースと、255件の違反が出たケースを区別する手段がありません。前者はstdout、後者はstderrに出るので、出力を読めば分かるのですが。

コマンドライン解説のドキュメント(2,150行あります)を exit でgrepしても、ヒットするのは --version と --help の説明文だけでした。終了コードの意味づけについての記述は見つけられていません。

静かに効かない設定がもうひとつあった

ここまでの流れで「checkstyleは黙っていることがある」という感触があったので、もう一箇所だけ確かめました。SuppressWarningsFilter です。

@SuppressWarnings("checkstyle:LineLength") を書いたJavaファイルに対して、フィルタの有無を振ってみます。

# 1. フィルタなし
$ java -jar checkstyle-14.1.0-all.jar -c only-linelength.xml Sup.java
[ERROR] /work/Sup.java:2: Line is longer than 20 characters (found 44). [LineLength]
[ERROR] /work/Sup.java:3: Line is longer than 20 characters (found 82). [LineLength]
$ echo $?   # → 2

# 2. SuppressWarningsFilter を足した(Holder なし)
$ java -jar checkstyle-14.1.0-all.jar -c with-filter.xml Sup.java
[ERROR] /work/Sup.java:2: Line is longer than 20 characters (found 44). [LineLength]
[ERROR] /work/Sup.java:3: Line is longer than 20 characters (found 82). [LineLength]
$ echo $?   # → 2  (1と完全に同じ)

# 3. TreeWalker 配下に SuppressWarningsHolder も入れた
$ java -jar checkstyle-14.1.0-all.jar -c with-filter-and-holder.xml Sup.java
$ echo $?   # → 0

2番目、フィルタを設定に書き足したのに結果が1番目と1バイトも変わりません。「そのフィルタは相棒がいないと動きませんよ」とは言ってくれない。TreeWalker の中に SuppressWarningsHolder を入れて初めて抑制が効いて0になります。

ここは公式のドキュメントに両方必要だと書いてあるので、読まずに書き足した僕が悪いんですが、設定ファイルに1行足して何も変わらなかったとき「効いていない」のか「効いた結果として指摘がない」のかが終了コードから判断できないのは、けっこう怖いなと思いました。

あと余談ですが、ファイル単位のチェックはJava以外のファイルも普通に監査します。47文字の .txt に LineLength max=20 をかけたら、ちゃんと [ERROR] /work/note.txt:1: Line is longer than 20 characters (found 47). が出て終了コード1になりました。ディレクトリを丸ごと渡すときは対象拡張子を意識したほうが良さそうです。

分かったことと、これから

学んだことを並べておきます。

  • checkstyleの終了コードは違反「件数」であって、成否のフラグではない
  • ただし1バイトなので256で一周する。256件・512件ちょうどは0になる
  • 不正な引数・設定ファイル不在も255で、255件の違反と区別がつかない
  • google_checksは既定severityがwarningなので、何件出ても終了コードは0
  • stderrの Checkstyle ends with N errors. は常に正しい件数を言っている

対処自体は簡単でした。google_checksを使いつつゲートにしたいなら、severityを上書きするだけです。

$ echo "org.checkstyle.google.severity=error" > sev.properties
$ java -jar checkstyle-14.1.0-all.jar -p sev.properties -c /google_checks.xml src/Hello.java
   ... 30件が [WARN] から [ERROR] に変わる
Checkstyle ends with 30 errors.
$ echo $?
30

# -D でも同じ結果になる
$ java -Dorg.checkstyle.google.severity=error -jar checkstyle-14.1.0-all.jar -c /google_checks.xml src/Hello.java
$ echo $?
30

同じ入力・同じ30件が、これだけでCIを止める側に回ります。ここは好みが分かれるところで、警告のまま運用して人が見る、という選び方もあると思います。ただ「終了コードで判定している」なら、severityの設定は判定条件そのものなので、意識して選ぶ必要があります。

ちなみに速度は気にならないレベルでした。小さいファイル1本をgoogle_checks(モジュール108個)で回して5回とも625〜674ミリ秒、514行のファイルを単一モジュールの設定で216ミリ秒です。ほとんどJVMの起動時間ですね。

僕はこれから、CIでcheckstyleを使うときは終了コードだけでなく Checkstyle ends with の行を見るようにします。あと、既存プロジェクトに入っているcheckstyleのseverityは一度確認しようと思いました。緑だから大丈夫、と思っていた期間が長すぎたので。