Clang Plugin APIで切り拓くカスタム静的解析:プロジェクト固有のレガシーコードを根絶する極北のアーキテクチャ
こんにちは。開発環境アーキテクトの私だ。
世の中には無数の静埋め込みチェッカーが存在する。Clang-Tidyは極めて強力であり、現代のC/C++開発においてなくてはならない存在だ。しかし、百戦錬磨のシニアエンジニアである君なら、こんな壁にぶつかったことがあるはずだ。
「うちのドメイン特有のレガシーな関数ラッパーへの移行を強制したい」
「セキュリティ監査要件で、特定の自社製マクロを通さないとビルドすらさせたくない」
「Clang-TidyのYAML設定や既存のチェックルールでは表現できない、文脈依存の複雑なコードパターンを検知したい」
汎用的なツールは、汎用であるがゆえに「組織の文脈」を理解できない。ならばどうするか? Clangのコンパイラフロントエンドに直接介入し、自分たちのルールをコンパイルプロセスそのものに焼き付ければいい。
今回は、Clang Plugin APIを直接叩き、既存のClang-Tidyではカバーしきれないプロジェクト固有のルールを強制する「カスタム構文チェッカー」の実装から、CI/CDパイプラインと完全に融合させたコンテナ駆動の自動化フローまで、妥協なきエンジニアリングの全貌を解説する。
—
1. 内部アーキテクチャ:Clang ASTとPlugin APIの深淵
まず、Clangがソースコードをどのように処理し、プラグインがどこに介入するのか、その内部データフローを理解しなければならない。
[ C/C++ Source Code ]
│
▼
[ Clang Lexer ] ──(トークン列)──► [ Clang Parser ]
│
(抽象構文木: AST)
│
▼
[ ASTConsumer (基底クラス) ]
│
(プラグインによる介入)
│
▼
[ RecursiveASTVisitor ]
(特定のノードパターンを走査)
│
▼
[ 違反検出時のDiagnosticEngine ]
(コンパイルエラーとして強制終了)
Clangのプラグイン機構(`PluginASTAction`)は、コンパイルの心臓部であるAST(Abstract Syntax Tree:抽象構文木)が生成された直後のフェーズにフックする。
ここで重要なのは、外部の独立した静的解析ツール(ソースコードをテキストや独立したパーサで舐めるもの)とは異なり、Clang自身が持つ完全な型情報、マクロ展開結果、テンプレート実体化の文脈をそのまま利用できるという点だ。これにより、誤検知(False Positive)を極限まで排除した、極めて精度の高い構文チェックが可能になる。
—
2. 実装:禁断のAPI・マクロをブロックするカスタムプラグイン
今回は、「特定のレガシー関数(例: `strcpy` や、自社製の非推奨な生ポインター操作関数 `legacy_alloc`)の呼び出しを検知したら、容赦なくコンパイルエラー(`diag::err_`)を発生させてビルドを破壊する」プラグインを実装する。
2.1. プラグインのソースコード (`LegacyCheckerPlugin.cpp`)
以下のコードは、最新のClang API(LLVM/Clang 16〜18系対応)を前提とした、プラグインのコア実装である。
include “clang/AST/AST.h”
include “clang/AST/ASTConsumer.h”
include “clang/AST/RecursiveASTVisitor.h”
include “clang/Frontend/ASTConsumers.h”
include “clang/Frontend/CompilerInstance.h”
include “clang/Frontend/FrontendPluginRegistry.h”
include “clang/Basic/Diagnostic.h”
include “llvm/Support/raw_ostream.h”
using namespace clang;
namespace {
// ==========================================
// 1. ASTを再帰的に走査し、条件に一致するノードを捕捉するVisitor
// ==========================================
class LegacyCheckerVisitor : public RecursiveASTVisitor
private:
ASTContext Context;
public:
explicit LegacyCheckerVisitor(ASTContext Context) : Context(Context) {}
// 関数呼び出し式(CallExpr)をフックする
bool VisitCallExpr(CallExpr Expr) {
if (FunctionDecl Callee = Expr->getDirectCallee()) {
std::string FuncName = Callee->getNameInfo().getAsString();
// 組織として絶対に使わせたくないレガシー関数のブラックリスト
if (FuncName == “strcpy” || FuncName == “legacy_alloc”) {
// 診断エンジン(DiagnosticEngine)を通じてエラーを発行する
DiagnosticsEngine &Diag = Context->getDiagnostics();
unsigned DiagID = Diag.getCustomDiagID(
DiagnosticsEngine::Error,
“【アーキテクチャ違反】禁止されたレガシー関数 ‘%0’ が検出されました。安全なラッパー関数を使用してください。”
);
// 該当ソースコードの位置情報を取得し、エラー箇所を正確に指し示す
SourceLocation Loc = Expr->getExprLoc();
Diag.Report(Loc, DiagID) << FuncName;
}
}
return true;
}
};
// ==========================================
// 2. AST全体をコンシューマーとして受け取り、Visitorを実行するクラス
// ==========================================
class LegacyCheckerConsumer : public ASTConsumer {
private:
LegacyCheckerVisitor Visitor;
public:
explicit LegacyCheckerConsumer(ASTContext Context) : Visitor(Context) {}
// ASTの構築が完了した単位(TranslationUnit)ごとに呼び出される
void HandleTranslationUnit(ASTContext &Context) override {
Visitor.TraverseDecl(Context.getTranslationUnitDecl());
}
};
// ==========================================
// 3. Clangフロントエンドにプラグインとして登録するアクションクラス
// ==========================================
class LegacyCheckerAction : public PluginASTAction {
protected:
std::unique_ptr
return std::make_unique
}
bool ParseArgs(const CompilerInstance & /CI/, const std::vector
// 必要に応じてコンパイル時引数(-Xclang -plugin-arg-xxx)をパース可能
return true;
}
};
} // namespace
// Clangのプラグインレジストリにこのモジュールを登録する
static FrontendPluginRegistry::Add
X(“legacy-checker”, “Enforce strict coding standards by rejecting legacy functions”);
—
3. ビルド環境の構築:CMakeによるモジュールコンパイル
Clangプラグインは、LLVM/Clang本体と同じバージョン、同じコンパイラフラグでビルドされた共有ライブラリ(`.so` または `.dylib`)である必要がある。環境差異によるセグメンテーション違反を防ぐため、Dockerを用いたビルド環境の固定が必須となる。
3.1. `CMakeLists.txt`
cmake_minimum_required(VERSION 3.20.0)
project(LegacyCheckerPlugin)
C++17標準を強制(LLVMのビルド要件に準拠)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
ホストにインストールされているLLVM/ClangのCMakeパッケージをロード
find_package(LLVM REQUIRED CONFIG)
find_package(Clang REQUIRED CONFIG)
include_directories(${LLVM_INCLUDE_DIRS})
include_directories(${CLANG_INCLUDE_DIRS})
共有ライブラリとしてプラグインをコンパイル
add_library(LegacyCheckerPlugin MODULE
LegacyCheckerPlugin.cpp
)
LLVM/Clangの内部ライブラリとリンク
target_link_libraries(LegacyCheckerPlugin PRIVATE
clangAST
clangBasic
clangFrontend
clangLex
clangAnalysis
)
モジュール名のサフィックス調整(環境依存の対策)
set_target_properties(LegacyCheckerPlugin PROPERTIES
PREFIX “”
SUFFIX “.so”
)
—
4. Dockerコンテナによる完全自動構成とビルドの再現性
開発者のローカルマシン環境に依存せず、CI/CDパイプラインでも完全に同一のバイナリを生成するため、マルチステージビルドを採用した `Dockerfile` を用意する。
4.1. `Dockerfile`
==========================================
ステージ1: ビルド環境(LLVM/Clangの開発ヘッダを含む重いイメージ)
==========================================
FROM debian:bookworm-slim AS builder
必要なビルドツールとLLVM/Clang開発パッケージを一括インストール
RUN apt-get update && apt-get install -y –no-install-recommends \
build-essential \
cmake \
ninja-build \
llvm-16-dev \
libclang-16-dev \
clang-16 \
libssl-dev \
git \
&& rm -rf /var/lib/apt/lists/
WORKDIR /app
COPY . /app
CMakeとNinjaを用いて高速ビルドを実行
RUN mkdir build && cd build \
&& cmake -G Ninja .. \
&& ninja
==========================================
ステージ2: 実行・検証用ランタイム環境
==========================================
FROM debian:bookworm-slim AS runtime
RUN apt-get update && apt-get install -y –no-install-recommends \
clang-16 \
make \
&& rm -rf /var/lib/apt/lists/
ビルドステージから生成されたプラグインの共有ライブラリのみを抽出・配置
COPY –from=builder /app/build/LegacyCheckerPlugin.so /usr/local/lib/LegacyCheckerPlugin.so
WORKDIR /workspace
—
5. CI/CDパイプラインとの高度な連携(GitHub Actions)
このプラグインを実際の開発フローに組み込む。開発者がプルリクエストを作成した際、コンパイル時に `-fload` と `-plugin` フラグを強制し、レガシーコードが含まれている場合はビルドを即座に失敗させる。
5.1. `.github/workflows/static-analysis.yml`
name: Enforce Custom Clang Plugin Rules
on:
pull_request:
branches: [ main, develop ]
jobs:
analyze:
runs-on: ubuntu-latest
container:
image: ghcr.io/your-org/clang-plugin-runner:latest # 事前ビルドしたカスタムDockerイメージ
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Run Build with Custom Clang Plugin
run: |
echo “=== 独自の構文チェッカーによるビルド検証を開始 ===”
# コンパイル時にClangプラグインをロードし、特定のチェックアクションを指定する
# -Xclang -load: プラグインの共有ライブラリを読み込む
# -Xclang -add-plugin: 登録したプラグインの名前を指定する
clang++-16 -std=c++17 \
-Xclang -load -Xclang /usr/local/lib/LegacyCheckerPlugin.so \
-Xclang -add-plugin -Xclang legacy-checker \
-c src/main.cpp -o /dev/null
echo “=== 検証完了: 違反がなければビルドは成功します ===”
—
6. 実行確認と検証ログ
もし開発者がうっかり `main.cpp` の中で `strcpy` を使用していた場合、CI/CDパイプラインやローカルビルドでは以下のような劇的なエラーメッセージが出力される。
$ clang++-16 -std=c++17 -Xclang -load -Xclang /usr/local/lib/LegacyCheckerPlugin.so -Xclang -add-plugin -Xclang legacy-checker -c src/main.cpp -o /dev/null
src/main.cpp:14:5: error: 【アーキテクチャ違反】禁止されたレガシー関数 ‘strcpy’ が検出されました。安全なラッパー関数を使用してください。
strcpy(dest, src);
^
1 error generated.
コンパイラの診断エンジン(Diagnostic Engine)と完全に統合されているため、IDE(VS CodeやCLionなど)のビルドタスクとも完璧に連動し、コーディングの瞬間にエディタ上で赤波線として警告を表示させることも可能だ。
—
7. アーキテクトからの実践的アドバイス:パフォーマンスと運用の極意
1. インクリメンタルビルドへの配慮:
プラグイン自体の変更頻度は低いため、共有ライブラリのロードオーバーヘッドはごく僅かである。しかし、大規模コードベースにおいてすべてのコンパイル単位(Translation Unit)でプラグインの走査走査が走ると、わずか数パーセントのビルド時間増大を招く。本当にチェックが必要なディレクトリ(例: レガシーから移行中のモジュール)に限定してCMakeのターゲットプロパティでフラグを制御する設計が望ましい。
2. LLVMバージョンの厳格な固定:
ClangのAST構造体はマイナーバージョンアップでもレイアウトが変化することがある。プラグインをビルドしたLLVM/Clangのバージョンと、実際にコードをコンパイルする際のコンパイラバージョンは、パッチレベルまで完全に一致させなければセグメンテーション違反を引き起こす。Dockerを活用したバージョン固定は、この運用リスクをゼロにするための唯一にして最善の解である。
汎用ツールに縛られるな。組織の哲学とアーキテクチャの保全は、自らの手によるコンパイラ拡張によってのみ、完全な自動化が達成されるのだ。