【実務・中級編】ClangのプラグインAPIで自分だけの構文チェッカーを作る:特定のコーディング規約を強制する – 実行環境・ランタイム・コンパイラ生産性向上バイブル

はじめに:なぜClang-Tidyでは足りないのか

こんにちは。テックリードの私たちが日々直面する最大の技術的負債の一つが、「プロジェクト固有のレガシーな作法や、独自のセキュリティラッパーの強制」です。

世の中には素晴らしい静的解析ツールが溢れています。例えば `Clang-Tidy` は現代のC/C++開発においてなくてはならない存在であり、`bugprone-` や `cert-` といった標準チェッカーを導入するだけで、多くの脆弱性を水際で防ぐことができます。

しかし、こんな壁にぶつかったことはありませんか?

  • 「我が社の基盤コードベースでは、メモリ確保に標準の `malloc` や `new` を直接使ってはならず、必ずカスタムアロケータを引数にとる `app_malloc(ptr, size, MEM_POOL_CORE)` を使わなければならない」
  • 「特定のレガシーなAPI(例: `strcpy` や `sprintf` だけでなく、社内製の非スレッドセーフなロガー関数)の呼び出しを検出し、コンパイルエラーとして弾きたい」

これらは汎用的なClang-Tidyのルール(例: `clang-diagnostic-` や既存の規約チェッカー)だけでは、コンテキスト(それがどのモジュールで呼ばれているか、特定のラッパー経由か)を完璧に判定して強制することが困難です。設定ファイルの正規表現マッチでは、マクロ展開や型情報の欠落によって簡単にすり抜けが発生します。

「無いなら、自分たちの手でClangのコンパイラフロントエンドに直接食い込むカスタムプラグインを作ればいい」

今回は、ClangのAST(抽象構文木)を直接ハックし、プロジェクト固有のコーディング規約を強制する「自製Clangプラグイン」の作り方を、実務に即したコードとビルドパイプラインへの組み込み方とともに徹底解説します。

—

1. Clang Plugin APIのアーキテクチャと動作原理

私たちが普段使っているGCCやClangは、ソースコードを読み込んで機械語を吐き出す「ブラックボックス」のように見えますが、内部は極めてモジュール化されたパイプラインで構成されています。

[Source Code (.c)]
↓
(Lexer / Parser) <-- 字句解析・構文解析 ↓ [Clang AST] <-- ★ここでプラグインが介入! ↓ (AST Consumer) <-- ノードを走査し、違反を検知して強制終了 ↓ (Code Generation) ↓ [Object File (.o)] Clangプラグインは、Clang自体のプロセス内にロードされる共有ライブラリ(`.so` または `.dylib`)です。プラグインは `ASTConsumer` と `ASTVisitor` という2つの強力なフックを提供します。

1. `ASTConsumer`: AST(抽象構文木)全体が構築されたタイミングで呼び出されるエントリポイント。
2. `RecursiveASTVisitor`: ASTのノード(関数定義、変数宣言、式など)を再帰的に訪問し、特定のパターン(例: 関数呼び出し `CallExpr`)をピンポイントで捕捉する訪問者パターン。

これらを組み合わせることで、「特定の関数名が使われていたら、その場でコンパイルを中断し、美しく分かりやすいエラーメッセージを出力する」という強力なチェッカーを数十行のC++で実装できます。

—

2. 実装:禁止されたAPIの直接呼び出しを検知・拒絶するプラグイン

今回は実践的な例として、「プロジェクト内で `system()` 関数の直接呼び出しを完全に禁止し、必ず社内ラッパーの `secure_system()` を使わせる」 というルールを強制するプラグイン `NoSystemCallPlugin` を実装します。

ソースコード:`NoSystemChecker.cpp`

以下のコードをプロジェクトのツール群ディレクトリ(例: `tools/clang-plugins/`)に配置してください。Clangの内部APIを使用するため、Clangの開発ヘッダーが必要です。

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 “llvm/Support/raw_ostream.h”

using namespace clang;

namespace {

// 1. ASTを走査して特定の条件(ここでは禁止された関数呼び出し)を検知するビジター
class NoSystemVisitor : public RecursiveASTVisitor {
private:
ASTContext Context;

public:
explicit NoSystemVisitor(ASTContext Context) : Context(Context) {}

// 関数呼び出し式(CallExpr)が現れるたびにこのメソッドが呼ばれる
bool VisitCallExpr(CallExpr Expr) {
// 呼び出されている関数自体の宣言を取得
if (FunctionDecl Callee = Expr->getDirectCallee()) {
std::string FuncName = Callee->getNameInfo().getAsString();

// 「system」関数が直接呼ばれているかを検知
if (FuncName == “system”) {
// ソースコード上の位置情報を取得
SourceLocat Loc = Expr->getBeginLoc();
FullSourceLoc FullLoc(Loc, Context->getSourceManager());

// 開発者に向けて、なぜダメなのか、どう修正すべきかをコンパイルエラーとして出力
DiagnosticsEngine &Diags = Context->getDiagnostics();
unsigned DiagID = Diags.getCustomDiagID(
DiagnosticsEngine::Error,
“【コーディング規約違反】’system()’の直接呼び出しはセキュリティポリシーにより禁止されています。”
” 代わりに ‘secure_system()’ を使用してください。”
);
Diags.Report(FullLoc, DiagID);
}
}
return true; // 走査を継続
}
};

// 2. AST全体を受け取り、ビジターを起動するConsumer
class NoSystemASTConsumer : public ASTConsumer {
private:
NoSystemVisitor Visitor;

public:
explicit NoSystemASTConsumer(ASTContext Context) : Visitor(Context) {}

// 翻訳単位(TranslationUnit)全体のAST構築が完了した際に実行される
void HandleTranslationUnit(ASTContext &Context) override {
Visitor.TraverseDecl(Context.getTranslationUnitDecl());
}
};

// 3. Clangのプラグイン機構に登録するためのフロントエンドアクション
class NoSystemAction : public PluginASTAction {
protected:
std::unique_ptr CreateASTConsumer(CompilerInstance &CI,
llvm::StringRef /InFile/) override {
return std::make_unique(&CI.getASTContext());
}

bool ParseArgs(const CompilerInstance &CI,
const std::vector &args) override {
// 必要に応じてプラグイン引数(例: 例外リストの追加など)をパース可能
return true;
}
};

} // namespace

// Clangのプラグインレジストリに登録
static FrontendPluginRegistry::Add
X(“no-system-plugin”, “Enforce secure system call wrapper”);

—

3. プラグインのビルドとCMake統合のベストプラクティス

Clangプラグインは、Clang自体のバージョン(LLVMバージョン)とABI(Application Binary Interface)が厳密に一致している必要があります。そのため、開発マシンにインストールされているLLVM/Clangの開発パッケージを使用してビルドします。

`CMakeLists.txt` の構成

cmake_minimum_required(VERSION 3.15)
project(NoSystemPlugin)

C++17以上が必須(LLVM/ClangのモダンなAPIを使用するため)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

システムにインストールされているLLVM/Clangの設定をロード
find_package(LLVM REQUIRED CONFIG)
find_package(Clang REQUIRED CONFIG)

message(STATUS “Found LLVM/Clang version: ${LLVM_PACKAGE_VERSION}”)

LLVMの定義とインクルードディレクトリをインポート
include_directories(${LLVM_INCLUDE_DIRS} ${CLANG_INCLUDE_DIRS})
add_definitions(${LLVM_DEFINITIONS})

共有ライブラリ(.so または .dylib)としてプラグインをビルド
add_library(NoSystemPlugin MODULE
NoSystemChecker.cpp
)

LLVM/Clangのライブラリとリンク
target_link_libraries(NoSystemPlugin PRIVATE
clangBasic
clangAST
clangFrontend
)

プラグインの出力名を設定(OSごとの拡張子を自動調整)
set_target_properties(NoSystemPlugin PROPERTIES
PREFIX “”
SUFFIX “.so”
)

—

4. ビルド&実行テスト:実際にコンパイルが弾かれる瞬間

それでは、実際にこのプラグインをビルドし、悪意ある(あるいはレガシーな)コードをコンパイルして挙動を確認してみましょう。

1. プラグインのビルド

mkdir build && cd build
cmake -DCMAKE_PREFIX_PATH=/usr/lib/llvm-15 .. # 環境にあわせてLLVMパスを指定
make
成功すると build/NoSystemPlugin.so が生成される

2. テスト用コードの用意

以下のテストコード `test.c` を用意します。

include

void execute_task(void) {
// 違反ケース:systemの直接呼び出し
system(“ls -la”);
}

void execute_secure_task(void) {
// 正常ケース:社内ラッパーの呼び出し
// secure_system(“ls -la”);
}

3. Clangへのプラグイン読み込みと実行コマンド

Clangにプラグインを読み込ませるには、`-fplugin` フラグと、プラグインに引数を渡す `-Xclang` を組み合わせます。

clang -fsyntax-only \
-fplugin=./NoSystemPlugin.so \
-Xclang -plugin-arg-no-system-plugin \
test.c

実行ログ(コンパイルエラー出力)

コマンドを実行すると、私たちが仕込んだカスタムエラーメッセージがコンパイラから正確に出力されます。

test.c:5:5: error: 【コーディング規約違反】’system()’の直接呼び出しはセキュリティポリシーにより禁止されています。 代わりに ‘secure_system()’ を使用してください。
system(“ls -la”);
^
1 error generated.

既存のビルドシステム(MakefileやCMake、あるいはBazel)のコンパイルフラグに `-fplugin` を組み込むだけで、全開発者のローカルビルドおよびCI/CDパイプラインにおいて、この規約違反を100%ブロックできるようになります。

—

5. チーム開発・CI/CDでの運用ルールと設定共有

このような強力なツールを現場に導入する際、単に「プラグインを入れておいてね」では誰も使いません。チーム全体の生産性を落とさず、かつ確実に統制を利かせるためのベストプラクティスを共有します。

A. 開発環境のコンテナ化(Docker)によるバージョン完全一致

前述の通り、ClangプラグインはLLVMのバージョン(例: LLVM 15とLLVM 16)が変わるだけでシンボル解決エラーを起こしてクラッシュします。
開発者のOS環境(Ubuntu, macOS, WSL2など)の差異を排除するため、ビルド環境とCI環境は必ず同一のDockerイメージに閉じ込めてください。

B. CMakeを通じたビルドシステムへの透過的な組み込み

開発者が手動で `-fplugin` を叩く必要はありません。プロジェクトの `CMakeLists.txt` で、特定のビルドタイプ(例: `Release` や `CI` モード)のときに自動でプラグインを適用するマクロを定義します。

CMakeによるプラグイン強制適用スニペットの一例
if(ENABLE_CUSTOM_CLANG_PLUGINS)
add_compile_options(
“-fplugin=${CMAKE_SOURCE_DIR}/tools/plugins/NoSystemPlugin.so”
)
endif()

—

おわりに:コードの品質は「仕組み」で担保する

規約やガイドラインをどれだけドキュメント化し、プルリクエストのレビューで人力チェックしても、ヒューマンエラーは必ずすり抜けます。

今回紹介したClangプラグインAPIを用いた独自チェッカーの自製は、一見するとハードルが高く見えるかもしれません。しかし、「コンパイラそのものを自分たちの開発チームの守護神に変える」このアプローチは、レガシーコードの駆逐と大規模プロジェクトの保守性を劇的に高める最強の投資です。

既存のツールに限界を感じたら、ぜひ自前のプラグイン開発に踏み出してください。あなたのチームのコードベースは、見違えるほど堅牢なものに生まれ変わるはずです。

タイトルとURLをコピーしました