はじめに
自作のC#静的解析ツール『CSharp_IngeniousAnalyzer』を、今も多くの方にダウンロードいただいており、心より感謝申し上げます!
これまでの記事:
- C#の不毛なコードレビューをゼロに!CSharp_IngeniousAnalyzer 全11ルール徹底解説
- C# 不毛なレビュー指摘を根絶したい!『CSharp_IngeniousAnalyzer』v3.1.0 アップデート!新ルール2つ(EXC001・STR003)を追加しました
今回は v3.4.0 で追加した新ルール 『UNI001(不可視Unicode文字の検知)』 を紹介します。
きっかけは、社内のエンジニアからのリクエストでした。
違いが分かりますか?
次のコードを実行すると、最後の行は False を出力します。
var a = "Hello World";
var b = "Hello World";
Console.WriteLine(a == b); // False
2つの文字列の違い、見つけられたでしょうか。
正解は、b の Hello と World の間にある空白でした。
見た目は半角スペースですが、中身は ノーブレークスペース(NBSP、U+00A0) という別の文字になっています。
HTMLの として表示される、あの空白です。
このコードブロックには本物のNBSPを仕込んであるので、コピーして実行すると本当に False になります。
目を凝らしても、エディタで見比べても、まず分かりません。
社内エンジニアからのリクエスト
ある日、社内のエンジニアから相談を受けました。
コードの中に、このNBSPが紛れ込んでいたというのです。
混入の経路として考えられるのは、コピー&ペーストでした。
- Copilot のチャット画面から、提案されたコードをコピーした
- WinMerge の差分画面から、コードをコピーした
推測ではありますが、画面表示の都合でNBSPになっていた空白を、文字コードを意識せずにそのまま貼り付けてしまったのではないかと考えています。
厄介なのは、C#のコンパイラがNBSPを普通の空白として扱う点です。
インデントやトークンの間に入り込んでもコンパイルは通るため、ビルドでは誰も気付けません。
文字列リテラルの中に入れば、先ほどのクイズのとおり、比較や Split(' ') が黙って食い違います。
そこで出てきたのが、「目で見て分からない文字が混ざっていたら警告してほしい」というリクエストでした。
目で見えないUnicodeを検知してみる
-
タイトル: 不可視Unicode文字の検知による不具合防止
-
メッセージ:
目視で判別できないUnicode文字 {0}({1})が混入しています。意図しない混入であれば、削除するか通常の文字に置き換えてください。 -
解説:
NBSPに限らず、目で見て判別できない文字はほかにもたくさんあります。本ルールは、文字列リテラル・識別子・空白・コメント・
#ifで無効化されたコードまで、ファイル内のどこに混入していても検知します。メッセージには
U+00A0(NO-BREAK SPACE)のように文字コードと名称を表示するので、何が混ざっているのかがすぐに分かります。
検知対象
| 分類 | 主な文字 | 何が起きるか |
|---|---|---|
| 特殊なスペース | NBSP(U+00A0)、U+2000〜U+200A など | 文字列比較や Split(' ') が食い違う |
| ゼロ幅文字 | ゼロ幅スペース(U+200B)、ZWJ(U+200D)、BOM(U+FEFF)など |
"ABC" == "ABC" が false になる、見た目が同じ別の変数ができる |
| 双方向制御文字 | U+202E(RIGHT-TO-LEFT OVERRIDE)など | 表示上のコードの並び順を偽装できる(Trojan Source攻撃) |
| 制御文字 | ESC、フォームフィード、C1制御文字など | 文字コードの取り違えなどで混入する |
| 改行に見えない改行 | U+2028、U+2029 など | C#では改行扱いになり、見た目と行の構造が食い違う |
| 空白に見える文字 | ハングルの埋め字(U+3164)など | 識別子に使えるため、見えない変数名を作れる |
| 結合用の濁点・半濁点 | U+3099、U+309A | 「か」+濁点が「が」と同じ見た目になる |
| タグ文字 | U+E0020〜U+E007F など | 見えない文字列をまるごと埋め込める |
日本語の開発現場ならではの落とし穴が、結合用の濁点です。
Macでファイル名をコピーすると、「が」が「か」+「゛」の2文字に分かれた形(NFD)になっていることがあります。
// 見た目はまったく同じ2つのファイル名
var x = "がいぎ資料.xlsx";
var y = "がいぎ資料.xlsx"; // ⚠ UNI001
Console.WriteLine(x == y); // False
検知しないケース(誤検知防止)
| ケース | 理由 |
|---|---|
| 全角スペース(U+3000) | 日本語の文字列やコメントで意図的に使われることが多いため |
"\u00A0" のようなエスケープシーケンス |
ソース上で目視できるため(意図的に使う場合はこの書き方を推奨) |
| ファイル先頭のBOM | エンコーディングの指定であり、混入ではないため |
検知される例/されない例:
// 🔴 検知される:見た目は半角スペースだが、中身はNBSP
var greeting = "Hello World";
// 🟢 検知されない:エスケープシーケンスで書けば、意図が一目で分かる
var greeting = "Hello\u00A0World";
実務プロジェクトに入れてみたら、警告が200件を超えた
完成したUNI001を、実際の業務プロジェクトへ導入してみました。
結果は、警告が200件以上。
どれも、日々の開発で何人もの目に触れてきたはずのコードでした。
それでも、これだけの不可視文字が潜んでいました。
レビューで見つからなかったのは、レビューする側の注意不足ではありません。
そもそも人間の目では見えないのです。
「見た目で分からないものを、機械に見つけてもらう」。
静的解析が本当に役に立つのは、まさにこういう場面だと実感しました。
いずれもコメントや関数ヘッダーでしたので、ロジックに影響はありませんでした。
しかし、気づけていないことに恐怖を感じました。
Fixは出さなかった
本アナライザーは 「Fix(Ctrl + .)で一瞬で直せる快適さ」 を大切にしてきました。
ところがUNI001は、最終的に 文字を書き換えるFixを1つも提供しない ルールになっています。
そこに至るまでの経緯を紹介します。
なお、本アナライザーは Claude Code(AnthropicのAIコーディングエージェント)と二人三脚で開発しています。
「値が変わらない」Fix
Claude Code が最初に提案したのは、次の2種類のFixでした。
| 場所 | Fixの内容 | 狙い |
|---|---|---|
| 文字列リテラル内 | NBSPを \u00A0 というエスケープシーケンスに置き換える |
実行時の値がまったく変わらない |
| 空白・コメント内 | NBSPを半角スペースに置き換える | C#の意味が変わらない |
どちらも「動作を一切変えない」ので、これまでのFixの基準(少しでも動作が変わるならFixは出さない)を満たしているように見えます。
本当に絶対に間違いなく100%安全か?
念のため「100%誤検知なくFixできるんですか」と確認すると、Claude Code は実際にコードを動かして、例外を1つ見つけてきました。
[CallerArgumentExpression] という属性は、呼び出し元の引数を ソースコードの文字列のまま 受け取ります。
static string Expr(string v, [CallerArgumentExpression("v")] string e = "") => e;
Expr("a b"); // Fix前: "a b"(間の空白はNBSP)
Expr("a\u00A0b"); // Fix後: "a\u00A0b"
値を保つはずのFixでも、この文字列だけは変わってしまいます。
ArgumentNullException.ThrowIfNull などの例外メッセージに影響する程度ではありますが、「100%」とは言えません。
そもそも、その状態が異常では?
対応方法を相談されたとき、私はこう返しました。
例の場合、そもそも文字列リテラルにHTMLの制御文字列を埋めようとしているわけですよね
その時点でそれ自体が異常だと思うのですが
文字列の中にNBSPがあるなら、それは十中八九、半角スペースのつもりで書いた バグ です。
そのバグを \u00A0 に置き換えると、どうなるでしょうか。
- 値は変わらないので、バグはそのまま残る
- 不可視文字ではなくなるので、警告だけが消える
- 一括修正(Fix All)を使えば、すべてのバグが「正しいコード」に見えるようになる
「値を変えない」という意味では安全でも、バグを正当化して隠してしまうFix でした。
一方で、正しい修正である「半角スペースに置き換える」「削除する」は、値を変えます。
しかも、どちらが正しいのか、それとも本当に意図的なのかは、アナライザーには判断できません。
直すのは人、Fixは Ignore だけ
結論として、文字を書き換えるFixはすべて取りやめました。
修正は人の目で判断して行い、意図的な箇所だけを抑制できるよう、// Ignore UNI001 を挿入するFixのみを提供しています。
// Fix適用後:文字はそのまま、直前にIgnoreコメントが入る
// Ignore UNI001
var greeting = "Hello World";
Ignoreコメントは、文字を含む 直近の文またはメンバー(フィールド・メソッド・クラスなど)の直前 に挿入します。
メソッドやクラスの直前に書けば、その内側すべてをまとめて抑制できます。
複数行の文字列の途中に不可視文字があっても、コメントは文字列の外側に入るので、文字列の中身を壊すことはありません。
テストは「全文字」で確かめる
検知対象の一覧が正しいかどうかは、境界の数文字を確かめるだけでは不安が残ります。
そこで、U+0000〜U+FFFF の全文字(サロゲート領域を除く)とタグ文字のブロックを1つのファイルに並べてアナライザーにかけ、検知された文字の集合が仕様の一覧と過不足なく一致することを確かめるテストを用意しました。
検知漏れと誤検知を、約6万4千文字すべてについて一度に確認できます。
ほかにも、次のようなケースをテストしています。
- 範囲の両端(U+0080とU+009Fなど)と、そのすぐ外側の見える文字(ハイフンU+2010、単独の濁点「゛」U+309Bなど)
- 空ファイル、ペアになっていないサロゲート、閉じていない文字列といった異常系
- Ignoreコメントの書式(大文字小文字は区別しない、
/* */形式やID違いでは抑制しない) - 属性・プロパティ・enum・switch・
#regionなど、13種類の構文でのIgnoreの挿入位置
最終的にUNI001のテストは106件になりました。
前回の記事で紹介したミューテーションテスト(判定条件をわざと壊して、テストが落ちるかを確かめる方法)も、25パターンすべてでテストが落ちることを確認しています。
Claude Codeと開発してみて
今回も、役割分担ははっきりしていました。
- 人間(私): 社内からのリクエストの受け止め、「そのFixは本当に正しいか」という問いかけ、Fixを出さないという最終判断
-
Claude Code: Unicodeの不可視文字の洗い出し、実装、
CallerArgumentExpressionのような盲点の発見、約6万4千文字の網羅テスト
印象的だったのは、AIと人間で「安全」の物差しが違っていたことです。
Claude Code は 「値が変わらないか」 で安全を測り、そのうえで値が変わる例外まで見つけてきました。
私は 「バグが直るか」 で見ていました。
どちらも必要な視点ですが、何をもって「正しい修正」とするかを決めるのは、やはり人間の役割 だと感じています。
おわりに
UNI001は、社内のエンジニアのひと言から生まれたルールです。
実務プロジェクトで200件以上の警告が出たことで、「見た目で分からない問題」を機械に任せる価値を、作った本人がいちばん実感しました。
Copilot などのAIチャットや差分ツールからコードをコピーする機会は、これからも増えていくでしょう。
冒頭のクイズで答えられなかった方こそ、ぜひ一度、お手元のプロジェクトで試してみてください。
もし実際に試してみて、この文字も検知してほしい、このケースは誤検知ではないか、新しいルール『XXX001』を追加してほしいといったご意見・アイデアがありましたら、ぜひ本記事のコメント欄や、GitHubの Issues / Discussions、NuGetページから気軽にお知らせください!
最後までお読みいただき、ありがとうございました!