diff --git a/.gitattributes b/.gitattributes index 82f41b1d..8712709a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -9,3 +9,6 @@ *.jpeg binary *.png binary *.zip binary + +# Preserve raw disassembler output while keeping whitespace checks enabled elsewhere. +FolderDiffIL4DotNet.Tests/Fixtures/ILCorpus/*.il whitespace=-blank-at-eol,-blank-at-eof diff --git a/CHANGELOG.md b/CHANGELOG.md index 72df58dd..ae99d19f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### [Unreleased] +#### Added + +- **Configurable IL substring normalization** — Added `ShouldILNormalizeContainingConfiguredStrings` and `ILNormalizeContainingStrings`. When enabled, every occurrence of each configured substring is replaced with one comparison-local marker absent from both raw IL inputs (normally ``, otherwise the first available numeric suffix), while all surrounding text on the same IL line remains comparable. Streaming comparison, fallback comparison, and materialized IL output share the selected marker. Both configured normalization and configured line exclusion apply across every IL instruction context, including program string literals loaded by `ldstr`. Leading and trailing whitespace in non-empty normalization values remains part of the exact match; whitespace-only values are ignored. This is intentionally separate from `ILIgnoreLineContainingStrings`, which continues to remove an entire matching line from comparison. Creator mode enables configured substring normalization automatically. Normalization values shorter than four characters now produce the same console/report safety warning class as short ignore values, with wording specific to the risk of normalizing legitimate IL content. +- **Real disassembler IL corpus** — Added reproducible source and pinned golden IL from `dotnet-ildasm 0.12.2.0` and `ilspycmd 9.1.0.7988`, the fallback baseline for inspecting assemblies targeting .NET 8/9/10. The signed test assembly covers ordinary classes and multiple methods, instance/static constructors, async and iterator state machines, lambdas and compiler-generated types, generic types and methods, properties, events, fields, signed AssemblyRefs, and reproducible COM interop metadata. A clearly identified test-only strong-name key keeps the sample assembly and public key token reproducible. ActiveX wrapper output is documented but excluded because portable `aximp` tooling and reproducible input are not available in the cross-platform .NET SDK. Corpus regeneration requires PowerShell 7 (`pwsh`); its .NET major-version roll-forward policy now covers tool version probes as well as build/generation and restores the caller's environment afterward. + +#### Changed + +- **Build-variant IL values are normalized instead of discarded** — MVID, method RVA, code-size, and WinForms `AxHost.TypeLibraryTimeStampAttribute` values are replaced with rule-specific stable markers in every mode. Each matching line prefix is preserved while the build-variant portion that follows it is replaced. For ILSpy's multiline type-library timestamp form, the complete byte blob through its closing `)` is collapsed into one marker. Built-in rules cover the actual comment and custom-attribute forms emitted by both supported disassemblers, including ILSpy's custom-attribute syntax without dotnet-ildasm's `class ` token, and are applied to every IL text regardless of which disassembler produced it; the disassembler recorded in reports identifies pattern provenance only. Normalization still occurs after IL cache retrieval, with no cache format or key change, so compatible existing cache entries remain reusable. +- **Creator defaults preserve more meaningful differences** — Renamed the built-in profile from `buildserver-winforms` to `creator-default`. Its predefined build-server/path substrings now populate `ILNormalizeContainingStrings` instead of `ILIgnoreLineContainingStrings`, preserving non-variant content on matching lines. Creator-profile values form the baseline and are applied first in profile order; user-configured values follow in their configured array order. The `.publickeytoken = (` suppression entry was removed and is not replaced by normalization, so AssemblyRef public key token changes remain visible in creator mode. +- **Normalization reporting and tool guidance are more explicit** — Markdown and HTML reports show built-in normalization rules, configured normalization substrings, and configured ignore substrings in that order. Built-in and configured-normalization tables state that their row order is the application order. The tables distinguish `Line Prefix Pattern`, `Substring to Normalize`, and `Substring to Ignore`; configured values are displayed without decorative quotes, while an enabled setting with no effective values is reported without rendering an empty table. The built-in table also shows each pattern's replacement marker and observed disassembler. Duplicate and containment-related normalization values now produce console/report warnings, including relationships that arise after creator-profile values are prepended to configured values. Guidance clarifies sequential raw-text matching order and inserted-marker protection, why the current built-in and creator rules are order-independent, that `nildiff` itself targets `net8.0`, and that `ilspycmd 9.1.0.7988` is pinned as the fallback baseline for inspecting assemblies targeting .NET 8/9/10. English/Japanese configuration documentation, schema, guides, and report samples are aligned with the new behavior. +- **Normalization configuration and report output are bounded and unambiguous** — The combined configured/profile normalization list is limited to 256 entries of at most 4096 Unicode characters each; creator-profile and duplicate entries count toward the limit. Per line, all configured rules together are limited to 65,536 non-overlapping replacements and a normalized result of at most 4,194,304 UTF-16 code units; either excess throws `InvalidDataException` before the expanded result is allocated. Relationship diagnostics emit at most 100 details plus a suppressed-count summary and are skipped when configured normalization is disabled. Configured substring values embedded in safety warnings use a single-line visible escape form for line breaks, other whitespace, control/format characters, combining marks, lone UTF-16 surrogates, and backslashes while keeping CommonMark punctuation inert, preventing configuration text from altering console/log or report structure. Markdown report tables use reversible escaped values so backslashes, invisible characters, and CommonMark punctuation remain distinguishable without becoming inline syntax; HTML reports keep their ordinary pre-wrapped display. + +#### Removed + +- **`ShouldIgnoreMVID` configuration setting and API** — Removed as a breaking change; MVID values are now always normalized to ``. Supplying the former JSON key or `FOLDERDIFF_SHOULDIGNOREMVID` environment variable fails with an explicit migration error instead of being accepted and ignored. +- **`buildserver-winforms` creator profile name** — Removed as a breaking change. Use `creator-default`; the former name follows the normal unknown-profile path and exits with invalid arguments. + ### [1.24.0] - 2026-07-29 #### Added @@ -1719,6 +1736,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### [Unreleased] +#### 追加 + +- **設定可能な IL 部分文字列正規化** — `ShouldILNormalizeContainingConfiguredStrings` と `ILNormalizeContainingStrings` を追加しました。有効時は、old/new双方のraw ILに存在しない比較ローカルマーカー(通常は ``、衝突時は未使用の最初の数値suffix)を1個選び、設定した各部分文字列のすべての出現箇所をそのマーカーへ置換します。同じIL行にある周辺テキストは比較可能な状態で残し、ストリーミング比較、fallback比較、実体化IL出力で同じマーカーを共有します。設定正規化と設定行除外はどちらも、`ldstr` が読み込むプログラム文字列リテラルを含む、すべての IL 命令文脈へ適用します。空でない正規化値の前後空白は正確な一致条件の一部として保持し、空白だけの値は無視します。これは、一致した行全体を比較から除外する従来の `ILIgnoreLineContainingStrings` とは独立した機能です。creatorモードでは設定文字列の正規化を自動的に有効化します。4文字未満の正規化値は、短い無視値と同じコンソール/レポート安全性警告の対象とし、正規のIL内容まで正規化するリスクが分かる文言で表示します。 +- **実逆アセンブラ IL コーパス** — 再生成可能なソースと、`dotnet-ildasm 0.12.2.0` および.NET 8/9/10をtarget frameworkとするAssemblyを調査するfallback baselineである `ilspycmd 9.1.0.7988` から取得したgolden ILを追加しました。署名済みテストAssemblyは、通常classと複数method、instance/static constructor、async/iterator state machine、lambda/compiler-generated type、generic type/method、property/event/field、署名済みAssemblyRef、再現可能なCOM interop metadataを網羅します。用途を明記したテスト専用strong-name鍵により、サンプルAssemblyとpublic key tokenの再現性を維持します。ActiveX wrapper出力は、cross-platform .NET SDKでportableな `aximp` ツールと再現可能な入力を用意できないため、除外理由のみ文書化しています。コーパス再生成にはPowerShell 7(`pwsh`)が必要で、.NET major-version roll-forwardポリシーはtool version probeからbuild/生成までを包含し、終了後に呼び出し元環境を復元します。 + +#### 変更 + +- **ビルド依存 IL 値を破棄せず正規化** — MVID、method RVA、code-size、WinFormsの `AxHost.TypeLibraryTimeStampAttribute` 値を、全モードで規則別の安定したマーカーへ置換するようにしました。照合した行接頭辞は保持し、それに続くビルド依存部分だけを置換します。ILSpyの複数行type-library timestamp形式では、閉じ `)` までのbyte blob全体を1個のマーカーへ縮約します。組み込み規則は、ILSpyのcustom attribute構文がdotnet-ildasmの `class ` tokenを含まない違いも含め、両対応逆アセンブラが実際に出力するコメント/custom attribute形式を網羅し、生成元の逆アセンブラに関係なくすべてのIL textへ適用します。レポート上の逆アセンブラ名はパターンの由来を示すだけです。正規化は従来どおりILキャッシュ取得後に行い、キャッシュ形式とキーは変更していないため、互換性のある既存キャッシュエントリは引き続き利用できます。 +- **creator既定値で意味のある差分をより多く保持** — 組み込みプロファイル名を `buildserver-winforms` から `creator-default` へ変更しました。既定のbuild-server/path部分文字列は `ILIgnoreLineContainingStrings` ではなく `ILNormalizeContainingStrings` へ設定し、一致行にあるビルド依存でない内容を保持します。creatorプロファイル値を基盤としてプロファイル内の順序で先に適用し、その後にユーザー設定値を設定配列順で適用します。`.publickeytoken = (` の抑制エントリは削除し、正規化対象にもしていないため、AssemblyRefのpublic key token変更はcreatorモードでも差分として残ります。 +- **正規化レポートとツール案内を明確化** — Markdown/HTMLレポートには、組み込み正規化規則、設定正規化部分文字列、設定無視部分文字列の順で表示します。組み込み表と設定正規化表には、行の並びが適用順であることを明記します。表では `Line Prefix Pattern`、`Substring to Normalize`、`Substring to Ignore` を区別し、設定値へ装飾的なダブルクオートを付けません。有効だが実効値が0件の場合は空の表を出さず、設定値がないことだけを明示します。組み込み表には各パターンの置換マーカーと確認元逆アセンブラも表示します。重複または包含関係にある正規化値は、creatorプロファイル値を設定値より前へ追加した結果生じた関係も含めてコンソール/レポート警告の対象にしました。raw textへの逐次一致順と挿入済みマーカーの保護、現行の組み込み規則とcreator規則で順序が結果へ影響しない理由、`nildiff` 本体が `net8.0` をtargetとすること、`ilspycmd 9.1.0.7988` を.NET 8/9/10をtarget frameworkとするAssembly調査の固定fallback baselineとすることを案内に明記しました。英日両方の設定文書、schema、guide、レポートサンプルを新しい挙動へ揃えました。 +- **正規化設定とレポート出力を有界かつ明確に** — 設定値とプロファイル値の結合後の正規化リストを最大256件、各値を最大4096 Unicode文字に制限し、creatorプロファイル値と重複値も件数上限へ算入します。設定した全規則を合わせて、1行の非重複置換を最大65,536件、正規化後の結果を最大4,194,304 UTF-16コード単位に制限し、いずれかを超える場合は展開後の結果を割り当てる前に `InvalidDataException` をスローします。関係診断は最大100件の詳細と超過分の抑制件数を示す要約に制限し、設定正規化が無効な場合は省略します。安全性警告へ埋め込む設定部分文字列値は、改行、その他の空白、control/format文字、combining mark、単独UTF-16 surrogate、backslashを1行の可視escapeにし、CommonMark記号を不活性化して、設定文字列がconsole/logやレポート構造を変更できないようにします。Markdownレポート表は可逆なescape表現を使い、backslash、不可視文字、CommonMark記号をinline構文にせず区別できます。HTMLレポートは通常の空白に対する既存のpre-wrap表示を維持します。 + +#### 削除 + +- **`ShouldIgnoreMVID` 設定とAPI** — 破壊的変更として削除しました。MVID値は常に `` へ正規化します。旧JSON keyまたは環境変数 `FOLDERDIFF_SHOULDIGNOREMVID` を指定すると、受理して無視せず明示的な移行エラーで失敗します。 +- **creatorプロファイル名 `buildserver-winforms`** — 破壊的変更として削除しました。`creator-default` を使用してください。旧名は通常の未知profile経路でinvalid argumentsとして終了します。 + ### [1.24.0] - 2026-07-29 #### 追加 diff --git a/Common/Constants.cs b/Common/Constants.cs index 884feb36..638203af 100644 --- a/Common/Constants.cs +++ b/Common/Constants.cs @@ -40,6 +40,9 @@ public static class Constants /// Name of the ILSpy command-line tool. / ILSpy コマンドラインツールの名前。 public const string ILSPY_CMD = "ilspycmd"; + /// Supported ilspycmd version. / サポート対象の ilspycmd バージョン。 + public const string ILSPY_CMD_VERSION = "9.1.0.7988"; + /// Error message for invalid parallelism values. / 不正な並列度の値に対するエラーメッセージ。 public const string ERROR_MAX_PARALLEL = "The maximum degree of parallelism must be 1 or greater."; @@ -89,13 +92,49 @@ public static class Constants public const int IL_CACHE_TIME_TO_LIVE_DEFAULT_HOURS = 12; /// - /// Line prefix used to identify and exclude MVID lines from IL output during comparison. + /// Line prefix used to identify MVID values for normalization during IL comparison. /// MVID (Module Version ID) is metadata that can change on every rebuild and does not /// represent a meaningful IL difference. - /// IL 出力から比較時に除外する MVID 行の接頭辞。 + /// IL 比較時に正規化する MVID 値を識別するための行接頭辞。 /// MVID は再ビルドごとに変わり得る Module Version ID メタデータで、実行される IL 差分を直接意味しない。 /// public const string IL_MVID_LINE_PREFIX = "// MVID:"; + /// + /// dotnet-ildasm line prefix used to identify RVA values for normalization during IL comparison. + /// IL 比較時に正規化する dotnet-ildasm の RVA 値を識別するための行接頭辞。 + /// + public const string IL_RVA_LINE_PREFIX = "// Method begins at Relative Virtual Address (RVA) 0x"; + + /// + /// ilspycmd line prefix used to identify RVA values for normalization during IL comparison. + /// IL 比較時に正規化する ilspycmd の RVA 値を識別するための行接頭辞。 + /// + public const string IL_ILSPY_RVA_LINE_PREFIX = "// Method begins at RVA 0x"; + + /// + /// dotnet-ildasm line prefix used to identify code-size values for normalization during IL comparison. + /// IL 比較時に正規化する dotnet-ildasm のコードサイズ値を識別するための行接頭辞。 + /// + public const string IL_CODE_SIZE_LINE_PREFIX = "// Code size "; + + /// + /// ilspycmd line prefix used to identify code-size values for normalization during IL comparison. + /// IL 比較時に正規化する ilspycmd のコードサイズ値を識別するための行接頭辞。 + /// + public const string IL_ILSPY_CODE_SIZE_LINE_PREFIX = "// Code size: "; + + /// + /// dotnet-ildasm line prefix used to identify WinForms type-library timestamps for normalization during IL comparison. + /// IL 比較時に正規化する dotnet-ildasm の WinForms タイプライブラリタイムスタンプを識別するための行接頭辞。 + /// + public const string IL_TYPE_LIBRARY_TIMESTAMP_LINE_PREFIX = ".custom instance void class [System.Windows.Forms]System.Windows.Forms.AxHost/TypeLibraryTimeStampAttribute::.ctor(string) = ( "; + + /// + /// ilspycmd line prefix used to identify WinForms type-library timestamps for normalization during IL comparison. + /// IL 比較時に正規化する ilspycmd の WinForms タイプライブラリタイムスタンプを識別するための行接頭辞。 + /// + public const string IL_ILSPY_TYPE_LIBRARY_TIMESTAMP_LINE_PREFIX = ".custom instance void [System.Windows.Forms]System.Windows.Forms.AxHost/TypeLibraryTimeStampAttribute::.ctor(string) = ("; + } } diff --git a/FolderDiffIL4DotNet.Tests/CliOptionsTests.Combined.cs b/FolderDiffIL4DotNet.Tests/CliOptionsTests.Combined.cs index 446f4c73..b254b397 100644 --- a/FolderDiffIL4DotNet.Tests/CliOptionsTests.Combined.cs +++ b/FolderDiffIL4DotNet.Tests/CliOptionsTests.Combined.cs @@ -20,7 +20,7 @@ public void ParseCliOptions_AllFlagsCombined_ParsedCorrectly() { "/old", "/new", "lbl", "--no-pause", "--no-il-cache", "--skip-il", - "--no-timestamp-warnings", "--creator", "--creator-il-ignore-profile", "buildserver-winforms", "--print-config", "--dry-run", + "--no-timestamp-warnings", "--creator", "--creator-il-ignore-profile", "creator-default", "--print-config", "--dry-run", "--fail-on-diff", "--coffee", "--bell", "--wizard", "--log-format", "json", @@ -31,7 +31,7 @@ public void ParseCliOptions_AllFlagsCombined_ParsedCorrectly() Assert.True(opts.SkipIL); Assert.True(opts.NoTimestampWarnings); Assert.True(opts.Creator); - Assert.Equal("buildserver-winforms", opts.CreatorIlIgnoreProfile); + Assert.Equal("creator-default", opts.CreatorIlIgnoreProfile); Assert.True(opts.PrintConfig); Assert.True(opts.DryRun); Assert.True(opts.FailOnDiff); diff --git a/FolderDiffIL4DotNet.Tests/CliOptionsTests.cs b/FolderDiffIL4DotNet.Tests/CliOptionsTests.cs index cbcfd9cb..e6db1726 100644 --- a/FolderDiffIL4DotNet.Tests/CliOptionsTests.cs +++ b/FolderDiffIL4DotNet.Tests/CliOptionsTests.cs @@ -354,9 +354,9 @@ public void ParseCliOptions_CreatorFlag_SetsCreator() [Fact] public void ParseCliOptions_CreatorIlIgnoreProfileWithKnownName_SetsProfile() { - var opts = CliParser.Parse(new[] { "--creator-il-ignore-profile", "buildserver-winforms" }); + var opts = CliParser.Parse(new[] { "--creator-il-ignore-profile", "creator-default" }); - Assert.Equal("buildserver-winforms", opts.CreatorIlIgnoreProfile); + Assert.Equal("creator-default", opts.CreatorIlIgnoreProfile); Assert.Null(opts.ParseError); } @@ -375,7 +375,7 @@ public void ParseCliOptions_CreatorIlIgnoreProfileWithUnknownName_SetsParseError var opts = CliParser.Parse(new[] { "--creator-il-ignore-profile", "missing-profile" }); Assert.NotNull(opts.ParseError); - Assert.Contains("buildserver-winforms", opts.ParseError, System.StringComparison.Ordinal); + Assert.Contains("creator-default", opts.ParseError, System.StringComparison.Ordinal); } // ----------------------------------------------------------------------- diff --git a/FolderDiffIL4DotNet.Tests/Fixtures/ILCorpus/README.md b/FolderDiffIL4DotNet.Tests/Fixtures/ILCorpus/README.md new file mode 100644 index 00000000..7a3c1156 --- /dev/null +++ b/FolderDiffIL4DotNet.Tests/Fixtures/ILCorpus/README.md @@ -0,0 +1,55 @@ +# Real IL output corpus + +## English + +This directory contains golden IL emitted from the same small, deterministic test assembly by two supported disassemblers: + +- `dotnet-ildasm 0.12.2.0`: `dotnet-ildasm-0.12.2.il` +- `ilspycmd 9.1.0.7988`: `ilspycmd-9.1.0.7988.il` + +The sample covers ordinary nested classes and methods, instance and static constructors, async and iterator state machines, lambdas and compiler-generated types, generic types and methods, properties, events, fields, a strong-name-signed assembly, signed framework AssemblyRefs, and reproducible COM interop metadata. + +The fixtures establish that `dotnet-ildasm` emits `// Method begins at Relative Virtual Address (RVA) 0x` and `// Code size `, while `ilspycmd` emits `// Method begins at RVA 0x` and `// Code size: `. They also establish that dotnet-ildasm custom-attribute signatures include `class ` before the assembly-qualified attribute type while ilspycmd signatures omit it. nildiff keeps separate built-in prefixes for these tool-specific forms and maps equivalent forms to the same normalization markers. All built-in rules are applied to every IL text; the disassembler name is provenance for the observed syntax, not an application condition. + +`nildiff` itself targets `net8.0` and requires the .NET 8 runtime. `ilspycmd 9.1.0.7988` is pinned as the fallback baseline for inspecting assemblies that target .NET 8, .NET 9, or .NET 10; those target-framework versions do not describe the runtime used to execute `nildiff`. + +The committed `ILCorpus.TestKey.snk` is a test-only strong-name key. It is public test material, not a credential, and must never be used to sign production assemblies. + +ActiveX wrapper output is not included. Reproducing `AxHost.TypeLibraryTimeStampAttribute` requires Windows-specific `aximp` input and tooling that are not part of the .NET SDK, so it would not be a portable, reproducible fixture. The type-specific normalization tests use each disassembler's custom-attribute grammar established by the committed fixtures and model ilspycmd's multiline byte blob through its closing `)`. The source-level COM interface and coclass metadata are included instead. + +From the repository root, regenerate both files with: + +```powershell +pwsh -File FolderDiffIL4DotNet.Tests/Fixtures/ILCorpus/regenerate.ps1 +``` + +Regeneration requires PowerShell 7 (`pwsh`), .NET SDK 8.0.423 from `global.json`, and both pinned disassemblers on `PATH`. The script uses cross-platform PowerShell APIs and the same command works on Windows, macOS, and Linux; fixture output should still be reviewed on the platform where it was generated. + +Review fixture changes before committing them. Disassembler upgrades intentionally require updating the pinned filenames, version checks, tests, and this document together. + +## 日本語 + +このディレクトリには、同じ小型の決定的テストAssemblyを、サポート対象の2つの逆アセンブラで処理したgolden ILを保存しています。 + +- `dotnet-ildasm 0.12.2.0`: `dotnet-ildasm-0.12.2.il` +- `ilspycmd 9.1.0.7988`: `ilspycmd-9.1.0.7988.il` + +サンプルには、通常のclass内の複数method、instance/static constructor、async/iterator state machine、lambda/compiler-generated type、generic type/method、property/event/field、strong-name署名Assembly、署名されたframework AssemblyRef、再現可能なCOM interop metadataが含まれます。 + +fixtureにより、`dotnet-ildasm` は `// Method begins at Relative Virtual Address (RVA) 0x` と `// Code size `、`ilspycmd` は `// Method begins at RVA 0x` と `// Code size: ` を出力することを固定しています。また、dotnet-ildasmのcustom attributeシグネチャはAssembly修飾付きattribute型の前に `class ` を含み、ilspycmdは含まないことも固定しています。nildiffはツール固有の形式ごとに組み込み接頭辞を持ち、同等の形式を同じ正規化マーカーへ変換します。すべての組み込み規則は全IL textへ適用され、逆アセンブラ名は確認した構文の由来を示すだけで適用条件ではありません。 + +`nildiff` 本体のtarget frameworkは `net8.0` で、実行には.NET 8 runtimeが必要です。`ilspycmd 9.1.0.7988` は、.NET 8、.NET 9、.NET 10をtarget frameworkとするAssemblyを調査するためのfallback baselineとして固定しています。これらのtarget framework versionは、`nildiff` 自体を実行するruntimeを意味しません。 + +コミットされている `ILCorpus.TestKey.snk` はテスト専用strong-name鍵です。公開テスト資材でありcredentialではありません。production Assemblyの署名には使用しないでください。 + +ActiveX wrapper出力は含めていません。`AxHost.TypeLibraryTimeStampAttribute` の再現には、.NET SDKに含まれないWindows固有の `aximp` 入力とツールが必要で、portableかつ再現可能なfixtureにできないためです。型固有の正規化テストでは、コミット済みfixtureで確認した各逆アセンブラのcustom attribute文法を使い、ilspycmdの複数行byte blobを閉じ `)` まで再現します。代わりにソースから再現できるCOM interfaceとcoclass metadataを含めています。 + +リポジトリルートから、次のコマンドで両方のファイルを再生成します。 + +```powershell +pwsh -File FolderDiffIL4DotNet.Tests/Fixtures/ILCorpus/regenerate.ps1 +``` + +再生成にはPowerShell 7(`pwsh`)、`global.json` で固定した.NET SDK 8.0.423、`PATH` 上の両固定バージョン逆アセンブラが必要です。このスクリプトはcross-platformなPowerShell APIを使用しており、Windows、macOS、Linuxで同じコマンドを使えますが、生成したplatform上でfixture差分を確認してください。 + +コミット前にfixture差分を確認してください。逆アセンブラを更新する場合は、固定バージョン入りファイル名、バージョン検証、テスト、この文書を同時に更新します。 diff --git a/FolderDiffIL4DotNet.Tests/Fixtures/ILCorpus/Source/AssemblyInfo.cs b/FolderDiffIL4DotNet.Tests/Fixtures/ILCorpus/Source/AssemblyInfo.cs new file mode 100644 index 00000000..f3792e2c --- /dev/null +++ b/FolderDiffIL4DotNet.Tests/Fixtures/ILCorpus/Source/AssemblyInfo.cs @@ -0,0 +1,9 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +[assembly: AssemblyTitle("nildiff IL corpus sample")] +[assembly: AssemblyDescription("Test-only assembly used to capture real disassembler output.")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] +[assembly: ComVisible(false)] +[assembly: Guid("d1d8c6cc-1d58-4d3b-9f2f-a0172c9c4331")] diff --git a/FolderDiffIL4DotNet.Tests/Fixtures/ILCorpus/Source/ILCorpus.Sample.csproj b/FolderDiffIL4DotNet.Tests/Fixtures/ILCorpus/Source/ILCorpus.Sample.csproj new file mode 100644 index 00000000..f2603b59 --- /dev/null +++ b/FolderDiffIL4DotNet.Tests/Fixtures/ILCorpus/Source/ILCorpus.Sample.csproj @@ -0,0 +1,15 @@ + + + net8.0 + ILCorpus.Sample + ILCorpus.Sample + enable + disable + true + none + false + false + true + ILCorpus.TestKey.snk + + diff --git a/FolderDiffIL4DotNet.Tests/Fixtures/ILCorpus/Source/ILCorpus.TestKey.snk b/FolderDiffIL4DotNet.Tests/Fixtures/ILCorpus/Source/ILCorpus.TestKey.snk new file mode 100644 index 00000000..9838f6e7 Binary files /dev/null and b/FolderDiffIL4DotNet.Tests/Fixtures/ILCorpus/Source/ILCorpus.TestKey.snk differ diff --git a/FolderDiffIL4DotNet.Tests/Fixtures/ILCorpus/Source/RepresentativeTypes.cs b/FolderDiffIL4DotNet.Tests/Fixtures/ILCorpus/Source/RepresentativeTypes.cs new file mode 100644 index 00000000..8f511293 --- /dev/null +++ b/FolderDiffIL4DotNet.Tests/Fixtures/ILCorpus/Source/RepresentativeTypes.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Threading.Tasks; + +namespace ILCorpus.Sample +{ + public sealed class RepresentativeType + where T : class, new() + { + public const int DefaultValue = 7; + public static readonly string StaticValue; + + private T _value; + + static RepresentativeType() + { + StaticValue = "initialized"; + } + + public RepresentativeType() + { + _value = new T(); + Name = string.Empty; + } + + public string Name { get; set; } + + public T Value + { + get => _value; + set + { + _value = value; + Changed?.Invoke(this, EventArgs.Empty); + } + } + + public event EventHandler? Changed; + + public int Add(int left, int right) + { + return left + right; + } + + public TResult Convert(Func converter) + { + return converter(_value); + } + + public async Task ComputeAsync(int value) + { + await Task.Yield(); + return value + DefaultValue; + } + + public IEnumerable CountTo(int maximum) + { + for (var value = 0; value < maximum; value++) + { + yield return value; + } + } + + public Func CreateAdder(int offset) + { + return value => value + offset; + } + } + + [ComVisible(true)] + [ComImport] + [Guid("58883045-92b4-4c9f-a42a-3e25a9b90f78")] + [InterfaceType(ComInterfaceType.InterfaceIsDual)] + public interface ICorpusComContract + { + [DispId(1)] + string Echo([MarshalAs(UnmanagedType.BStr)] string value); + + [DispId(2)] + int Number { get; set; } + } + + [ComVisible(true)] + [Guid("432398e1-334f-45af-8387-82f34b8ac526")] + [ClassInterface(ClassInterfaceType.None)] + public sealed class CorpusComClass : ICorpusComContract + { + public int Number { get; set; } + + public string Echo(string value) + { + return value; + } + } +} diff --git a/FolderDiffIL4DotNet.Tests/Fixtures/ILCorpus/dotnet-ildasm-0.12.2.il b/FolderDiffIL4DotNet.Tests/Fixtures/ILCorpus/dotnet-ildasm-0.12.2.il new file mode 100644 index 00000000..76b36963 --- /dev/null +++ b/FolderDiffIL4DotNet.Tests/Fixtures/ILCorpus/dotnet-ildasm-0.12.2.il @@ -0,0 +1,641 @@ + +.assembly extern System.Runtime +{ + .publickeytoken = ( B0 3F 5F 7F 11 D5 0A 3A ) // .._..... + .ver 8:0:0:0 +} + +.assembly extern System.Runtime.InteropServices +{ + .publickeytoken = ( B0 3F 5F 7F 11 D5 0A 3A ) // .._..... + .ver 8:0:0:0 +} + +.assembly extern System.Threading +{ + .publickeytoken = ( B0 3F 5F 7F 11 D5 0A 3A ) // .._..... + .ver 8:0:0:0 +} + +.assembly 'ILCorpus.Sample' +{ + .custom instance void class [System.Runtime]System.Runtime.CompilerServices.CompilationRelaxationsAttribute::.ctor(int32) = ( 01 00 08 00 00 00 00 00 ) // ........ + .custom instance void class [System.Runtime]System.Runtime.CompilerServices.RuntimeCompatibilityAttribute::.ctor() = ( 01 00 01 00 54 02 16 57 72 61 70 4E 6F 6E 45 78 63 65 70 74 69 6F 6E 54 68 72 6F 77 73 01 ) // ....T..WrapNonExceptionThrows. + .custom instance void class [System.Runtime]System.Reflection.AssemblyTitleAttribute::.ctor(string) = ( 01 00 18 6E 69 6C 64 69 66 66 20 49 4C 20 63 6F 72 70 75 73 20 73 61 6D 70 6C 65 00 00 ) // ...nildiff.IL.corpus.sample.. + .custom instance void class [System.Runtime]System.Reflection.AssemblyDescriptionAttribute::.ctor(string) = ( 01 00 3C 54 65 73 74 2D 6F 6E 6C 79 20 61 73 73 65 6D 62 6C 79 20 75 73 65 64 20 74 6F 20 63 61 70 74 75 72 65 20 72 65 61 6C 20 64 69 73 61 73 73 65 6D 62 6C 65 72 20 6F 75 74 70 75 74 2E 00 00 ) // ...Test.only.assembly.used.to.capture.real.disassembler.output... + .custom instance void class [System.Runtime]System.Reflection.AssemblyFileVersionAttribute::.ctor(string) = ( 01 00 07 31 2E 30 2E 30 2E 30 00 00 ) // ...1.0.0.0.. + .custom instance void class [System.Runtime]System.Runtime.InteropServices.ComVisibleAttribute::.ctor(bool) = ( 01 00 00 00 00 ) // ..... + .custom instance void class [System.Runtime.InteropServices]System.Runtime.InteropServices.GuidAttribute::.ctor(string) = ( 01 00 24 64 31 64 38 63 36 63 63 2D 31 64 35 38 2D 34 64 33 62 2D 39 66 32 66 2D 61 30 31 37 32 63 39 63 34 33 33 31 00 00 ) // ...d1d8c6cc.1d58.4d3b.9f2f.a0172c9c4331.. + .custom instance void class [System.Runtime]System.Runtime.Versioning.TargetFrameworkAttribute::.ctor(string) = ( 01 00 18 2E 4E 45 54 43 6F 72 65 41 70 70 2C 56 65 72 73 69 6F 6E 3D 76 38 2E 30 01 00 54 0E 14 46 72 61 6D 65 77 6F 72 6B 44 69 73 70 6C 61 79 4E 61 6D 65 08 2E 4E 45 54 20 38 2E 30 ) // ....NETCoreApp.Version.v8.0..T..FrameworkDisplayName..NET.8.0 + .hash algorithm 0x00008004 + .ver 1:0:0:0 +} + +.module 'ILCorpus.Sample.dll' +// MVID: {06e8a859-d81d-48da-a59a-eb5b8dc94c2e} +.imagebase 0x10000000 +.file alignment 0x00000200 +.stackreserve 0x00100000 +.subsystem 0x0003 // WindowsCui +.corflags 0x00000009 // + +.class public auto ansi sealed ILCorpus.Sample.RepresentativeType`1 extends [System.Runtime]System.Object +{ + .custom instance void class [System.Runtime]System.Runtime.CompilerServices.NullableContextAttribute::.ctor(byte) = ( 01 00 01 00 00 ) // ..... + .custom instance void class [System.Runtime]System.Runtime.CompilerServices.NullableAttribute::.ctor(byte) = ( 01 00 00 00 00 ) // ..... + .field public static int32 DefaultValue + .field public static initonly string StaticValue + .field private var _value + .field private string 'k__BackingField' + .custom instance void class [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = ( 01 00 00 00 ) // .... + .field private class [System.Runtime]System.EventHandler Changed + .custom instance void class [System.Runtime]System.Runtime.CompilerServices.NullableAttribute::.ctor(byte) = ( 01 00 02 00 00 ) // ..... + .custom instance void class [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = ( 01 00 00 00 ) // .... + .method private hidebysig specialname rtspecialname static default void .cctor() cil managed + { + // Method begins at Relative Virtual Address (RVA) 0x2050 + // Code size 11 (0xB) + .maxstack 8 + IL_0000: ldstr "initialized" + IL_0005: stsfld string [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1::StaticValue + IL_000a: ret + } // End of method System.Void ILCorpus.Sample.RepresentativeType`1::.cctor() + .method public hidebysig specialname rtspecialname instance default void .ctor() cil managed + { + // Method begins at Relative Virtual Address (RVA) 0x205C + // Code size 29 (0x1D) + .maxstack 8 + IL_0000: ldarg.0 + IL_0001: call instance void class [System.Runtime]System.Object::.ctor() + IL_0006: ldarg.0 + IL_0007: call !!0 class [System.Runtime]System.Activator::CreateInstance() + IL_000c: stfld var [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1::_value + IL_0011: ldarg.0 + IL_0012: ldsfld string string::Empty + IL_0017: call instance void class [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1::set_Name(string) + IL_001c: ret + } // End of method System.Void ILCorpus.Sample.RepresentativeType`1::.ctor() + .method public hidebysig specialname instance default string get_Name() cil managed + { + .custom instance void class [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = ( 01 00 00 00 ) // .... + // Method begins at Relative Virtual Address (RVA) 0x207A + // Code size 7 (0x7) + .maxstack 8 + IL_0000: ldarg.0 + IL_0001: ldfld string [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1::'k__BackingField' + IL_0006: ret + } // End of method System.String ILCorpus.Sample.RepresentativeType`1::get_Name() + .method public hidebysig specialname instance default void set_Name(string 'value') cil managed + { + .custom instance void class [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = ( 01 00 00 00 ) // .... + // Method begins at Relative Virtual Address (RVA) 0x2082 + // Code size 8 (0x8) + .maxstack 8 + IL_0000: ldarg.0 + IL_0001: ldarg.1 + IL_0002: stfld string [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1::'k__BackingField' + IL_0007: ret + } // End of method System.Void ILCorpus.Sample.RepresentativeType`1::set_Name(System.String) + .method public hidebysig specialname instance default var get_Value() cil managed + { + // Method begins at Relative Virtual Address (RVA) 0x208B + // Code size 7 (0x7) + .maxstack 8 + IL_0000: ldarg.0 + IL_0001: ldfld var [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1::_value + IL_0006: ret + } // End of method T ILCorpus.Sample.RepresentativeType`1::get_Value() + .method public hidebysig specialname instance default void set_Value(var 'value') cil managed + { + // Method begins at Relative Virtual Address (RVA) 0x2093 + // Code size 30 (0x1E) + .maxstack 8 + IL_0000: ldarg.0 + IL_0001: ldarg.1 + IL_0002: stfld var [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1::_value + IL_0007: ldarg.0 + IL_0008: ldfld class [System.Runtime]System.EventHandler [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1::Changed + IL_000d: dup + IL_000e: brtrue.s IL_0012 + IL_0010: pop + IL_0011: ret + IL_0012: ldarg.0 + IL_0013: ldsfld class [System.Runtime]System.EventArgs [System.Runtime]System.EventArgs::Empty + IL_0018: callvirt instance void class [System.Runtime]System.EventHandler::Invoke([System.Runtime]System.Object, [System.Runtime]System.EventArgs) + IL_001d: ret + } // End of method System.Void ILCorpus.Sample.RepresentativeType`1::set_Value(T) + .method public hidebysig specialname instance default void add_Changed(class [System.Runtime]System.EventHandler 'value') cil managed + { + .custom instance void class [System.Runtime]System.Runtime.CompilerServices.NullableContextAttribute::.ctor(byte) = ( 01 00 02 00 00 ) // ..... + .custom instance void class [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = ( 01 00 00 00 ) // .... + // Method begins at Relative Virtual Address (RVA) 0x20B4 + // Code size 41 (0x29) + .maxstack 3 + .locals init(class [System.Runtime]System.EventHandler V_0, class [System.Runtime]System.EventHandler V_1, class [System.Runtime]System.EventHandler V_2) + IL_0000: ldarg.0 + IL_0001: ldfld class [System.Runtime]System.EventHandler [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1::Changed + IL_0006: stloc.0 + IL_0007: ldloc.0 + IL_0008: stloc.1 + IL_0009: ldloc.1 + IL_000a: ldarg.1 + IL_000b: call [System.Runtime]System.Delegate class [System.Runtime]System.Delegate::Combine([System.Runtime]System.Delegate, [System.Runtime]System.Delegate) + IL_0010: castclass class System.EventHandler + IL_0015: stloc.2 + IL_0016: ldarg.0 + IL_0017: ldflda class [System.Runtime]System.EventHandler [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1::Changed + IL_001c: ldloc.2 + IL_001d: ldloc.1 + IL_001e: call !!0 class [System.Threading]System.Threading.Interlocked::CompareExchange(!!0&, !!0, !!0) + IL_0023: stloc.0 + IL_0024: ldloc.0 + IL_0025: ldloc.1 + IL_0026: bne.un.s IL_0007 + IL_0028: ret + } // End of method System.Void ILCorpus.Sample.RepresentativeType`1::add_Changed(System.EventHandler) + .method public hidebysig specialname instance default void remove_Changed(class [System.Runtime]System.EventHandler 'value') cil managed + { + .custom instance void class [System.Runtime]System.Runtime.CompilerServices.NullableContextAttribute::.ctor(byte) = ( 01 00 02 00 00 ) // ..... + .custom instance void class [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = ( 01 00 00 00 ) // .... + // Method begins at Relative Virtual Address (RVA) 0x20EC + // Code size 41 (0x29) + .maxstack 3 + .locals init(class [System.Runtime]System.EventHandler V_0, class [System.Runtime]System.EventHandler V_1, class [System.Runtime]System.EventHandler V_2) + IL_0000: ldarg.0 + IL_0001: ldfld class [System.Runtime]System.EventHandler [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1::Changed + IL_0006: stloc.0 + IL_0007: ldloc.0 + IL_0008: stloc.1 + IL_0009: ldloc.1 + IL_000a: ldarg.1 + IL_000b: call [System.Runtime]System.Delegate class [System.Runtime]System.Delegate::Remove([System.Runtime]System.Delegate, [System.Runtime]System.Delegate) + IL_0010: castclass class System.EventHandler + IL_0015: stloc.2 + IL_0016: ldarg.0 + IL_0017: ldflda class [System.Runtime]System.EventHandler [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1::Changed + IL_001c: ldloc.2 + IL_001d: ldloc.1 + IL_001e: call !!0 class [System.Threading]System.Threading.Interlocked::CompareExchange(!!0&, !!0, !!0) + IL_0023: stloc.0 + IL_0024: ldloc.0 + IL_0025: ldloc.1 + IL_0026: bne.un.s IL_0007 + IL_0028: ret + } // End of method System.Void ILCorpus.Sample.RepresentativeType`1::remove_Changed(System.EventHandler) + .method public hidebysig instance default int32 Add(int32 left, int32 right) cil managed + { + // Method begins at Relative Virtual Address (RVA) 0x2121 + // Code size 4 (0x4) + .maxstack 8 + IL_0000: ldarg.1 + IL_0001: ldarg.2 + IL_0002: add + IL_0003: ret + } // End of method System.Int32 ILCorpus.Sample.RepresentativeType`1::Add(System.Int32,System.Int32) + .method public hidebysig instance default mvar Convert(class [System.Runtime]System.Func`2 converter) cil managed + { + // Method begins at Relative Virtual Address (RVA) 0x2126 + // Code size 13 (0xD) + .maxstack 8 + IL_0000: ldarg.1 + IL_0001: ldarg.0 + IL_0002: ldfld var [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1::_value + IL_0007: callvirt instance var class [System.Runtime]System.Func`2::Invoke(var) + IL_000c: ret + } // End of method TResult ILCorpus.Sample.RepresentativeType`1::Convert(System.Func`2) + .method public hidebysig instance default [System.Runtime]System.Threading.Tasks.Task`1 ComputeAsync(int32 'value') cil managed + { + .custom instance void class [System.Runtime]System.Runtime.CompilerServices.AsyncStateMachineAttribute::.ctor([System.Runtime]System.Type) = ( 01 00 38 49 4C 43 6F 72 70 75 73 2E 53 61 6D 70 6C 65 2E 52 65 70 72 65 73 65 6E 74 61 74 69 76 65 54 79 70 65 60 31 2B 3C 43 6F 6D 70 75 74 65 41 73 79 6E 63 3E 64 5F 5F 31 37 00 00 ) // ..8ILCorpus.Sample.RepresentativeType.1..ComputeAsync.d__17.. + // Method begins at Relative Virtual Address (RVA) 0x2134 + // Code size 55 (0x37) + .maxstack 2 + .locals init(class [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/d__17 V_0) + IL_0000: ldloca.s class V_0 + IL_0002: call [System.Runtime]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1 class [System.Runtime]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1::Create() + IL_0007: stfld class [System.Runtime]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1 [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/d__17::'<>t__builder' + IL_000c: ldloca.s class V_0 + IL_000e: ldarg.1 + IL_000f: stfld int32 [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/d__17::value + IL_0014: ldloca.s class V_0 + IL_0016: ldc.i4.m1 + IL_0017: stfld int32 [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/d__17::'<>1__state' + IL_001c: ldloca.s class V_0 + IL_001e: ldflda class [System.Runtime]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1 [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/d__17::'<>t__builder' + IL_0023: ldloca.s class V_0 + IL_0025: call instance System.Void class [System.Runtime]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1::Start< ILCorpus.Sample.RepresentativeType`1/d__17>(!!0&) + IL_002a: ldloca.s class V_0 + IL_002c: ldflda class [System.Runtime]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1 [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/d__17::'<>t__builder' + IL_0031: call instance [System.Runtime]System.Threading.Tasks.Task`1 class [System.Runtime]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1::get_Task() + IL_0036: ret + } // End of method System.Threading.Tasks.Task`1 ILCorpus.Sample.RepresentativeType`1::ComputeAsync(System.Int32) + .method public hidebysig instance default [System.Runtime]System.Collections.Generic.IEnumerable`1 CountTo(int32 maximum) cil managed + { + .custom instance void class [System.Runtime]System.Runtime.CompilerServices.IteratorStateMachineAttribute::.ctor([System.Runtime]System.Type) = ( 01 00 33 49 4C 43 6F 72 70 75 73 2E 53 61 6D 70 6C 65 2E 52 65 70 72 65 73 65 6E 74 61 74 69 76 65 54 79 70 65 60 31 2B 3C 43 6F 75 6E 74 54 6F 3E 64 5F 5F 31 38 00 00 ) // ..3ILCorpus.Sample.RepresentativeType.1..CountTo.d__18.. + // Method begins at Relative Virtual Address (RVA) 0x2177 + // Code size 15 (0xF) + .maxstack 8 + IL_0000: ldc.i4.s -2 + IL_0002: newobj instance void class [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/d__18::.ctor(int32) + IL_0007: dup + IL_0008: ldarg.1 + IL_0009: stfld int32 [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/d__18::'<>3__maximum' + IL_000e: ret + } // End of method System.Collections.Generic.IEnumerable`1 ILCorpus.Sample.RepresentativeType`1::CountTo(System.Int32) + .method public hidebysig instance default [System.Runtime]System.Func`2 CreateAdder(int32 offset) cil managed + { + // Method begins at Relative Virtual Address (RVA) 0x2187 + // Code size 24 (0x18) + .maxstack 8 + IL_0000: newobj instance void class [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/<>c__DisplayClass19_0::.ctor() + IL_0005: dup + IL_0006: ldarg.1 + IL_0007: stfld int32 [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/<>c__DisplayClass19_0::offset + IL_000c: ldftn instance int32 class [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/<>c__DisplayClass19_0::b__0(int32) + IL_0012: newobj instance void class [System.Runtime]System.Func`2::.ctor([System.Runtime]System.Object, [System.Runtime]System.IntPtr) + IL_0017: ret + } // End of method System.Func`2 ILCorpus.Sample.RepresentativeType`1::CreateAdder(System.Int32) + .property instance string Name () + { + .get instance default string ILCorpus.Sample.RepresentativeType`1::get_Name () + .set instance default void ILCorpus.Sample.RepresentativeType`1::set_Name (string 'value') + } // End of property System.String ILCorpus.Sample.RepresentativeType`1::Name() + .property instance var Value () + { + .get instance default var ILCorpus.Sample.RepresentativeType`1::get_Value () + .set instance default void ILCorpus.Sample.RepresentativeType`1::set_Value (var 'value') + } // End of property T ILCorpus.Sample.RepresentativeType`1::Value() + .event class [System.Runtime]System.EventHandler Changed + { + .custom instance void class [System.Runtime]System.Runtime.CompilerServices.NullableAttribute::.ctor(byte) = ( 01 00 02 00 00 ) // ..... + .addon instance default void ILCorpus.Sample.RepresentativeType`1::add_Changed (class [System.Runtime]System.EventHandler 'value') + .removeon instance default void ILCorpus.Sample.RepresentativeType`1::remove_Changed (class [System.Runtime]System.EventHandler 'value') + } // End of property System.EventHandler ILCorpus.Sample.RepresentativeType`1::Changed + + .class nested private auto ansi sealed beforefieldinit <>c__DisplayClass19_0 extends [System.Runtime]System.Object + { + .custom instance void class [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = ( 01 00 00 00 ) // .... + .field public int32 offset + .method public hidebysig specialname rtspecialname instance default void .ctor() cil managed + { + // Method begins at Relative Virtual Address (RVA) 0x21BC + // Code size 7 (0x7) + .maxstack 8 + IL_0000: ldarg.0 + IL_0001: call instance void class [System.Runtime]System.Object::.ctor() + IL_0006: ret + } // End of method System.Void ILCorpus.Sample.RepresentativeType`1/<>c__DisplayClass19_0::.ctor() + .method hidebysig instance default int32 b__0(int32 'value') cil managed + { + // Method begins at Relative Virtual Address (RVA) 0x21C4 + // Code size 9 (0x9) + .maxstack 8 + IL_0000: ldarg.1 + IL_0001: ldarg.0 + IL_0002: ldfld int32 [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/<>c__DisplayClass19_0::offset + IL_0007: add + IL_0008: ret + } // End of method System.Int32 ILCorpus.Sample.RepresentativeType`1/<>c__DisplayClass19_0::b__0(System.Int32) + } // End of class ILCorpus.Sample.RepresentativeType`1/<>c__DisplayClass19_0 + + .class nested private auto ansi sealed beforefieldinit d__17 extends [System.Runtime]System.ValueType implements [System.Runtime]System.Runtime.CompilerServices.IAsyncStateMachine + { + .custom instance void class [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = ( 01 00 00 00 ) // .... + .field public int32 '<>1__state' + .field public class [System.Runtime]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1 '<>t__builder' + .custom instance void class [System.Runtime]System.Runtime.CompilerServices.NullableAttribute::.ctor(byte) = ( 01 00 00 00 00 ) // ..... + .field public int32 value + .field private valuetype [System.Runtime]System.Runtime.CompilerServices.YieldAwaitable/YieldAwaiter '<>u__1' + .method private hidebysig newslot virtual final instance default void MoveNext() cil managed + { + // Method begins at Relative Virtual Address (RVA) 0x21D0 + // Code size 157 (0x9D) + .maxstack 3 + .locals init(int32 V_0, int32 V_1, valuetype [System.Runtime]System.Runtime.CompilerServices.YieldAwaitable/YieldAwaiter V_2, valuetype [System.Runtime]System.Runtime.CompilerServices.YieldAwaitable V_3, class [System.Runtime]System.Exception V_4) + IL_0000: ldarg.0 + IL_0001: ldfld int32 [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/d__17::'<>1__state' + IL_0006: stloc.0 + .try + { + IL_0007: ldloc.0 + IL_0008: brfalse.s IL_0041 + IL_000a: call valuetype [System.Runtime]System.Runtime.CompilerServices.YieldAwaitable class [System.Runtime]System.Threading.Tasks.Task::Yield() + IL_000f: stloc.3 + IL_0010: ldloca.s class V_3 + IL_0012: call instance valuetype [System.Runtime]System.Runtime.CompilerServices.YieldAwaitable/YieldAwaiter class valuetype [System.Runtime]System.Runtime.CompilerServices.YieldAwaitable::GetAwaiter() + IL_0017: stloc.2 + IL_0018: ldloca.s class V_2 + IL_001a: call instance bool class valuetype [System.Runtime]System.Runtime.CompilerServices.YieldAwaitable/YieldAwaiter::get_IsCompleted() + IL_001f: brtrue.s IL_005d + IL_0021: ldarg.0 + IL_0022: ldc.i4.0 + IL_0023: dup + IL_0024: stloc.0 + IL_0025: stfld int32 [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/d__17::'<>1__state' + IL_002a: ldarg.0 + IL_002b: ldloc.2 + IL_002c: stfld valuetype [System.Runtime]System.Runtime.CompilerServices.YieldAwaitable/YieldAwaiter [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/d__17::'<>u__1' + IL_0031: ldarg.0 + IL_0032: ldflda class [System.Runtime]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1 [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/d__17::'<>t__builder' + IL_0037: ldloca.s class V_2 + IL_0039: ldarg.0 + IL_003a: call instance System.Void class [System.Runtime]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1::AwaitUnsafeOnCompletedd__17>(!!0&, !!1&) + IL_003f: leave.s IL_009c + IL_0041: ldarg.0 + IL_0042: ldfld valuetype [System.Runtime]System.Runtime.CompilerServices.YieldAwaitable/YieldAwaiter [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/d__17::'<>u__1' + IL_0047: stloc.2 + IL_0048: ldarg.0 + IL_0049: ldflda valuetype [System.Runtime]System.Runtime.CompilerServices.YieldAwaitable/YieldAwaiter [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/d__17::'<>u__1' + IL_004e: initobj class System.Runtime.CompilerServices.YieldAwaitable/YieldAwaiter + IL_0054: ldarg.0 + IL_0055: ldc.i4.m1 + IL_0056: dup + IL_0057: stloc.0 + IL_0058: stfld int32 [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/d__17::'<>1__state' + IL_005d: ldloca.s class V_2 + IL_005f: call instance void class valuetype [System.Runtime]System.Runtime.CompilerServices.YieldAwaitable/YieldAwaiter::GetResult() + IL_0064: ldarg.0 + IL_0065: ldfld int32 [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/d__17::value + IL_006a: ldc.i4.7 + IL_006b: add + IL_006c: stloc.1 + IL_006d: leave.s IL_0088 + } + catch [System.Runtime]System.Exception + { + IL_006f: stloc.s class V_4 + IL_0071: ldarg.0 + IL_0072: ldc.i4.s -2 + IL_0074: stfld int32 [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/d__17::'<>1__state' + IL_0079: ldarg.0 + IL_007a: ldflda class [System.Runtime]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1 [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/d__17::'<>t__builder' + IL_007f: ldloc.s class V_4 + IL_0081: call instance void class [System.Runtime]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1::SetException([System.Runtime]System.Exception) + IL_0086: leave.s IL_009c + } + IL_0088: ldarg.0 + IL_0089: ldc.i4.s -2 + IL_008b: stfld int32 [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/d__17::'<>1__state' + IL_0090: ldarg.0 + IL_0091: ldflda class [System.Runtime]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1 [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/d__17::'<>t__builder' + IL_0096: ldloc.1 + IL_0097: call instance void class [System.Runtime]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1::SetResult(var) + IL_009c: ret + } // End of method System.Void ILCorpus.Sample.RepresentativeType`1/d__17::MoveNext() + .method private hidebysig newslot virtual final instance default void SetStateMachine(class [System.Runtime]System.Runtime.CompilerServices.IAsyncStateMachine stateMachine) cil managed + { + .custom instance void class [System.Runtime]System.Diagnostics.DebuggerHiddenAttribute::.ctor() = ( 01 00 00 00 ) // .... + // Method begins at Relative Virtual Address (RVA) 0x228C + // Code size 13 (0xD) + .maxstack 8 + IL_0000: ldarg.0 + IL_0001: ldflda class [System.Runtime]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1 [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/d__17::'<>t__builder' + IL_0006: ldarg.1 + IL_0007: call instance void class [System.Runtime]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1::SetStateMachine([System.Runtime]System.Runtime.CompilerServices.IAsyncStateMachine) + IL_000c: ret + } // End of method System.Void ILCorpus.Sample.RepresentativeType`1/d__17::SetStateMachine(System.Runtime.CompilerServices.IAsyncStateMachine) + } // End of class ILCorpus.Sample.RepresentativeType`1/d__17 + + .class nested private auto ansi sealed beforefieldinit d__18 extends [System.Runtime]System.Object implements [System.Runtime]System.Collections.Generic.IEnumerable`1, [System.Runtime]System.Collections.IEnumerable, [System.Runtime]System.Collections.Generic.IEnumerator`1, [System.Runtime]System.Collections.IEnumerator, [System.Runtime]System.IDisposable + { + .custom instance void class [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = ( 01 00 00 00 ) // .... + .field private int32 '<>1__state' + .field private int32 '<>2__current' + .field private int32 '<>l__initialThreadId' + .field private int32 maximum + .field public int32 '<>3__maximum' + .field private int32 '5__2' + .method public hidebysig specialname rtspecialname instance default void .ctor(int32 <>1__state) cil managed + { + .custom instance void class [System.Runtime]System.Diagnostics.DebuggerHiddenAttribute::.ctor() = ( 01 00 00 00 ) // .... + // Method begins at Relative Virtual Address (RVA) 0x229A + // Code size 25 (0x19) + .maxstack 8 + IL_0000: ldarg.0 + IL_0001: call instance void class [System.Runtime]System.Object::.ctor() + IL_0006: ldarg.0 + IL_0007: ldarg.1 + IL_0008: stfld int32 [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/d__18::'<>1__state' + IL_000d: ldarg.0 + IL_000e: call int32 class [System.Runtime]System.Environment::get_CurrentManagedThreadId() + IL_0013: stfld int32 [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/d__18::'<>l__initialThreadId' + IL_0018: ret + } // End of method System.Void ILCorpus.Sample.RepresentativeType`1/d__18::.ctor(System.Int32) + .method private hidebysig newslot virtual final instance default void System.IDisposable.Dispose() cil managed + { + .custom instance void class [System.Runtime]System.Diagnostics.DebuggerHiddenAttribute::.ctor() = ( 01 00 00 00 ) // .... + // Method begins at Relative Virtual Address (RVA) 0x22B4 + // Code size 1 (0x1) + .maxstack 8 + IL_0000: ret + } // End of method System.Void ILCorpus.Sample.RepresentativeType`1/d__18::System.IDisposable.Dispose() + .method private hidebysig newslot virtual final instance default bool MoveNext() cil managed + { + // Method begins at Relative Virtual Address (RVA) 0x22B8 + // Code size 92 (0x5C) + .maxstack 3 + .locals init(int32 V_0, int32 V_1) + IL_0000: ldarg.0 + IL_0001: ldfld int32 [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/d__18::'<>1__state' + IL_0006: stloc.0 + IL_0007: ldloc.0 + IL_0008: brfalse.s IL_0010 + IL_000a: ldloc.0 + IL_000b: ldc.i4.1 + IL_000c: beq.s IL_0035 + IL_000e: ldc.i4.0 + IL_000f: ret + IL_0010: ldarg.0 + IL_0011: ldc.i4.m1 + IL_0012: stfld int32 [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/d__18::'<>1__state' + IL_0017: ldarg.0 + IL_0018: ldc.i4.0 + IL_0019: stfld int32 [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/d__18::'5__2' + IL_001e: br.s IL_004c + IL_0020: ldarg.0 + IL_0021: ldarg.0 + IL_0022: ldfld int32 [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/d__18::'5__2' + IL_0027: stfld int32 [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/d__18::'<>2__current' + IL_002c: ldarg.0 + IL_002d: ldc.i4.1 + IL_002e: stfld int32 [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/d__18::'<>1__state' + IL_0033: ldc.i4.1 + IL_0034: ret + IL_0035: ldarg.0 + IL_0036: ldc.i4.m1 + IL_0037: stfld int32 [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/d__18::'<>1__state' + IL_003c: ldarg.0 + IL_003d: ldfld int32 [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/d__18::'5__2' + IL_0042: stloc.1 + IL_0043: ldarg.0 + IL_0044: ldloc.1 + IL_0045: ldc.i4.1 + IL_0046: add + IL_0047: stfld int32 [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/d__18::'5__2' + IL_004c: ldarg.0 + IL_004d: ldfld int32 [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/d__18::'5__2' + IL_0052: ldarg.0 + IL_0053: ldfld int32 [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/d__18::maximum + IL_0058: blt.s IL_0020 + IL_005a: ldc.i4.0 + IL_005b: ret + } // End of method System.Boolean ILCorpus.Sample.RepresentativeType`1/d__18::MoveNext() + .method private hidebysig newslot virtual specialname final instance default int32 System.Collections.Generic.IEnumerator.get_Current() cil managed + { + .custom instance void class [System.Runtime]System.Diagnostics.DebuggerHiddenAttribute::.ctor() = ( 01 00 00 00 ) // .... + // Method begins at Relative Virtual Address (RVA) 0x2320 + // Code size 7 (0x7) + .maxstack 8 + IL_0000: ldarg.0 + IL_0001: ldfld int32 [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/d__18::'<>2__current' + IL_0006: ret + } // End of method System.Int32 ILCorpus.Sample.RepresentativeType`1/d__18::System.Collections.Generic.IEnumerator.get_Current() + .method private hidebysig newslot virtual final instance default void System.Collections.IEnumerator.Reset() cil managed + { + .custom instance void class [System.Runtime]System.Diagnostics.DebuggerHiddenAttribute::.ctor() = ( 01 00 00 00 ) // .... + // Method begins at Relative Virtual Address (RVA) 0x2328 + // Code size 6 (0x6) + .maxstack 8 + IL_0000: newobj instance void class [System.Runtime]System.NotSupportedException::.ctor() + IL_0005: throw + } // End of method System.Void ILCorpus.Sample.RepresentativeType`1/d__18::System.Collections.IEnumerator.Reset() + .method private hidebysig newslot virtual specialname final instance default [System.Runtime]System.Object System.Collections.IEnumerator.get_Current() cil managed + { + .custom instance void class [System.Runtime]System.Diagnostics.DebuggerHiddenAttribute::.ctor() = ( 01 00 00 00 ) // .... + .param [0] + .custom instance void class [System.Runtime]System.Runtime.CompilerServices.NullableAttribute::.ctor(byte) = ( 01 00 00 00 00 ) // ..... + // Method begins at Relative Virtual Address (RVA) 0x232F + // Code size 12 (0xC) + .maxstack 8 + IL_0000: ldarg.0 + IL_0001: ldfld int32 [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/d__18::'<>2__current' + IL_0006: box class System.Int32 + IL_000b: ret + } // End of method System.Object ILCorpus.Sample.RepresentativeType`1/d__18::System.Collections.IEnumerator.get_Current() + .method private hidebysig newslot virtual final instance default [System.Runtime]System.Collections.Generic.IEnumerator`1 System.Collections.Generic.IEnumerable.GetEnumerator() cil managed + { + .custom instance void class [System.Runtime]System.Diagnostics.DebuggerHiddenAttribute::.ctor() = ( 01 00 00 00 ) // .... + // Method begins at Relative Virtual Address (RVA) 0x233C + // Code size 55 (0x37) + .maxstack 2 + .locals init(class [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/d__18 V_0) + IL_0000: ldarg.0 + IL_0001: ldfld int32 [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/d__18::'<>1__state' + IL_0006: ldc.i4.s -2 + IL_0008: bne.un.s IL_0022 + IL_000a: ldarg.0 + IL_000b: ldfld int32 [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/d__18::'<>l__initialThreadId' + IL_0010: call int32 class [System.Runtime]System.Environment::get_CurrentManagedThreadId() + IL_0015: bne.un.s IL_0022 + IL_0017: ldarg.0 + IL_0018: ldc.i4.0 + IL_0019: stfld int32 [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/d__18::'<>1__state' + IL_001e: ldarg.0 + IL_001f: stloc.0 + IL_0020: br.s IL_0029 + IL_0022: ldc.i4.0 + IL_0023: newobj instance void class [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/d__18::.ctor(int32) + IL_0028: stloc.0 + IL_0029: ldloc.0 + IL_002a: ldarg.0 + IL_002b: ldfld int32 [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/d__18::'<>3__maximum' + IL_0030: stfld int32 [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/d__18::maximum + IL_0035: ldloc.0 + IL_0036: ret + } // End of method System.Collections.Generic.IEnumerator`1 ILCorpus.Sample.RepresentativeType`1/d__18::System.Collections.Generic.IEnumerable.GetEnumerator() + .method private hidebysig newslot virtual final instance default [System.Runtime]System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() cil managed + { + .custom instance void class [System.Runtime]System.Diagnostics.DebuggerHiddenAttribute::.ctor() = ( 01 00 00 00 ) // .... + // Method begins at Relative Virtual Address (RVA) 0x237F + // Code size 7 (0x7) + .maxstack 8 + IL_0000: ldarg.0 + IL_0001: call instance [System.Runtime]System.Collections.Generic.IEnumerator`1 class [ILCorpus.Sample.dll]ILCorpus.Sample.RepresentativeType`1/d__18::System.Collections.Generic.IEnumerable.GetEnumerator() + IL_0006: ret + } // End of method System.Collections.IEnumerator ILCorpus.Sample.RepresentativeType`1/d__18::System.Collections.IEnumerable.GetEnumerator() + .property instance int32 System.Collections.Generic.IEnumerator.Current () + { + .get instance default int32 ILCorpus.Sample.RepresentativeType`1/d__18::System.Collections.Generic.IEnumerator.get_Current () + } // End of property System.Int32 ILCorpus.Sample.RepresentativeType`1/d__18::System.Collections.Generic.IEnumerator.Current() + .property instance [System.Runtime]System.Object System.Collections.IEnumerator.Current () + { + .get instance default [System.Runtime]System.Object ILCorpus.Sample.RepresentativeType`1/d__18::System.Collections.IEnumerator.get_Current () + } // End of property System.Object ILCorpus.Sample.RepresentativeType`1/d__18::System.Collections.IEnumerator.Current() + } // End of class ILCorpus.Sample.RepresentativeType`1/d__18 +} // End of class ILCorpus.Sample.RepresentativeType`1 + +.class public interface abstract auto ansi beforefieldinit ILCorpus.Sample.ICorpusComContract +{ + .custom instance void class [System.Runtime]System.Runtime.CompilerServices.NullableContextAttribute::.ctor(byte) = ( 01 00 01 00 00 ) // ..... + .custom instance void class [System.Runtime]System.Runtime.InteropServices.ComVisibleAttribute::.ctor(bool) = ( 01 00 01 00 00 ) // ..... + .custom instance void class [System.Runtime.InteropServices]System.Runtime.InteropServices.GuidAttribute::.ctor(string) = ( 01 00 24 35 38 38 38 33 30 34 35 2D 39 32 62 34 2D 34 63 39 66 2D 61 34 32 61 2D 33 65 32 35 61 39 62 39 30 66 37 38 00 00 ) // ...58883045.92b4.4c9f.a42a.3e25a9b90f78.. + .custom instance void class [System.Runtime.InteropServices]System.Runtime.InteropServices.InterfaceTypeAttribute::.ctor(valuetype [System.Runtime.InteropServices]System.Runtime.InteropServices.ComInterfaceType) = ( 01 00 00 00 00 00 00 00 ) // ........ + .method public hidebysig newslot abstract virtual instance default string Echo(string 'value') cil managed + { + .custom instance void class [System.Runtime.InteropServices]System.Runtime.InteropServices.DispIdAttribute::.ctor(int32) = ( 01 00 01 00 00 00 00 00 ) // ........ + } // End of method System.String ILCorpus.Sample.ICorpusComContract::Echo(System.String) + .method public hidebysig newslot abstract virtual specialname instance default int32 get_Number() cil managed + { + } // End of method System.Int32 ILCorpus.Sample.ICorpusComContract::get_Number() + .method public hidebysig newslot abstract virtual specialname instance default void set_Number(int32 'value') cil managed + { + } // End of method System.Void ILCorpus.Sample.ICorpusComContract::set_Number(System.Int32) + .property instance int32 Number () + { + .custom instance void class [System.Runtime.InteropServices]System.Runtime.InteropServices.DispIdAttribute::.ctor(int32) = ( 01 00 02 00 00 00 00 00 ) // ........ + .get instance default int32 ILCorpus.Sample.ICorpusComContract::get_Number () + .set instance default void ILCorpus.Sample.ICorpusComContract::set_Number (int32 'value') + } // End of property System.Int32 ILCorpus.Sample.ICorpusComContract::Number() +} // End of class ILCorpus.Sample.ICorpusComContract + +.class public auto ansi sealed beforefieldinit ILCorpus.Sample.CorpusComClass extends [System.Runtime]System.Object implements ILCorpus.Sample.ICorpusComContract +{ + .custom instance void class [System.Runtime]System.Runtime.InteropServices.ComVisibleAttribute::.ctor(bool) = ( 01 00 01 00 00 ) // ..... + .custom instance void class [System.Runtime.InteropServices]System.Runtime.InteropServices.GuidAttribute::.ctor(string) = ( 01 00 24 34 33 32 33 39 38 65 31 2D 33 33 34 66 2D 34 35 61 66 2D 38 33 38 37 2D 38 32 66 33 34 62 38 61 63 35 32 36 00 00 ) // ...432398e1.334f.45af.8387.82f34b8ac526.. + .custom instance void class [System.Runtime.InteropServices]System.Runtime.InteropServices.ClassInterfaceAttribute::.ctor(valuetype [System.Runtime.InteropServices]System.Runtime.InteropServices.ClassInterfaceType) = ( 01 00 00 00 00 00 00 00 ) // ........ + .field private int32 'k__BackingField' + .custom instance void class [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = ( 01 00 00 00 ) // .... + .method public hidebysig newslot virtual specialname final instance default int32 get_Number() cil managed + { + .custom instance void class [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = ( 01 00 00 00 ) // .... + // Method begins at Relative Virtual Address (RVA) 0x21A0 + // Code size 7 (0x7) + .maxstack 8 + IL_0000: ldarg.0 + IL_0001: ldfld int32 ILCorpus.Sample.CorpusComClass::'k__BackingField' + IL_0006: ret + } // End of method System.Int32 ILCorpus.Sample.CorpusComClass::get_Number() + .method public hidebysig newslot virtual specialname final instance default void set_Number(int32 'value') cil managed + { + .custom instance void class [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = ( 01 00 00 00 ) // .... + // Method begins at Relative Virtual Address (RVA) 0x21A8 + // Code size 8 (0x8) + .maxstack 8 + IL_0000: ldarg.0 + IL_0001: ldarg.1 + IL_0002: stfld int32 ILCorpus.Sample.CorpusComClass::'k__BackingField' + IL_0007: ret + } // End of method System.Void ILCorpus.Sample.CorpusComClass::set_Number(System.Int32) + .method public hidebysig newslot virtual final instance default string Echo(string 'value') cil managed + { + .custom instance void class [System.Runtime]System.Runtime.CompilerServices.NullableContextAttribute::.ctor(byte) = ( 01 00 01 00 00 ) // ..... + // Method begins at Relative Virtual Address (RVA) 0x21B1 + // Code size 2 (0x2) + .maxstack 8 + IL_0000: ldarg.1 + IL_0001: ret + } // End of method System.String ILCorpus.Sample.CorpusComClass::Echo(System.String) + .method public hidebysig specialname rtspecialname instance default void .ctor() cil managed + { + // Method begins at Relative Virtual Address (RVA) 0x21B4 + // Code size 7 (0x7) + .maxstack 8 + IL_0000: ldarg.0 + IL_0001: call instance void class [System.Runtime]System.Object::.ctor() + IL_0006: ret + } // End of method System.Void ILCorpus.Sample.CorpusComClass::.ctor() + .property instance int32 Number () + { + .get instance default int32 ILCorpus.Sample.CorpusComClass::get_Number () + .set instance default void ILCorpus.Sample.CorpusComClass::set_Number (int32 'value') + } // End of property System.Int32 ILCorpus.Sample.CorpusComClass::Number() +} // End of class ILCorpus.Sample.CorpusComClass diff --git a/FolderDiffIL4DotNet.Tests/Fixtures/ILCorpus/ilspycmd-9.1.0.7988.il b/FolderDiffIL4DotNet.Tests/Fixtures/ILCorpus/ilspycmd-9.1.0.7988.il new file mode 100644 index 00000000..596cf33d --- /dev/null +++ b/FolderDiffIL4DotNet.Tests/Fixtures/ILCorpus/ilspycmd-9.1.0.7988.il @@ -0,0 +1,979 @@ +// IL code: ILCorpus.Sample +.class private auto ansi '' +{ +} // end of class + +.class public auto ansi sealed ILCorpus.Sample.RepresentativeType`1 + extends [System.Runtime]System.Object +{ + .custom instance void [System.Runtime]System.Runtime.CompilerServices.NullableContextAttribute::.ctor(uint8) = ( + 01 00 01 00 00 + ) + .custom instance void [System.Runtime]System.Runtime.CompilerServices.NullableAttribute::.ctor(uint8) = ( + 01 00 00 00 00 + ) + // Nested Types + .class nested private auto ansi sealed beforefieldinit '<>c__DisplayClass19_0' + extends [System.Runtime]System.Object + { + .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = ( + 01 00 00 00 + ) + // Fields + .field public int32 offset + + // Methods + .method public hidebysig specialname rtspecialname + instance void .ctor () cil managed + { + // Method begins at RVA 0x21bc + // Header size: 1 + // Code size: 7 (0x7) + .maxstack 8 + + IL_0000: ldarg.0 + IL_0001: call instance void [System.Runtime]System.Object::.ctor() + IL_0006: ret + } // end of method '<>c__DisplayClass19_0'::.ctor + + .method assembly hidebysig + instance int32 'b__0' ( + int32 'value' + ) cil managed + { + // Method begins at RVA 0x21c4 + // Header size: 1 + // Code size: 9 (0x9) + .maxstack 8 + + IL_0000: ldarg.1 + IL_0001: ldarg.0 + IL_0002: ldfld int32 class ILCorpus.Sample.RepresentativeType`1/'<>c__DisplayClass19_0'::offset + IL_0007: add + IL_0008: ret + } // end of method '<>c__DisplayClass19_0'::'b__0' + + } // end of class <>c__DisplayClass19_0 + + .class nested private auto ansi sealed beforefieldinit 'd__17' + extends [System.Runtime]System.ValueType + implements [System.Runtime]System.Runtime.CompilerServices.IAsyncStateMachine + { + .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = ( + 01 00 00 00 + ) + .interfaceimpl type [System.Runtime]System.Runtime.CompilerServices.IAsyncStateMachine + .custom instance void [System.Runtime]System.Runtime.CompilerServices.NullableAttribute::.ctor(uint8) = ( + 01 00 00 00 00 + ) + + // Fields + .field public int32 '<>1__state' + .field public valuetype [System.Runtime]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1 '<>t__builder' + .custom instance void [System.Runtime]System.Runtime.CompilerServices.NullableAttribute::.ctor(uint8) = ( + 01 00 00 00 00 + ) + .field public int32 'value' + .field private valuetype [System.Runtime]System.Runtime.CompilerServices.YieldAwaitable/YieldAwaiter '<>u__1' + + // Methods + .method private final hidebysig newslot virtual + instance void MoveNext () cil managed + { + .override method instance void [System.Runtime]System.Runtime.CompilerServices.IAsyncStateMachine::MoveNext() + // Method begins at RVA 0x21d0 + // Header size: 12 + // Code size: 157 (0x9d) + .maxstack 3 + .locals init ( + [0] int32, + [1] int32, + [2] valuetype [System.Runtime]System.Runtime.CompilerServices.YieldAwaitable/YieldAwaiter, + [3] valuetype [System.Runtime]System.Runtime.CompilerServices.YieldAwaitable, + [4] class [System.Runtime]System.Exception + ) + + IL_0000: ldarg.0 + IL_0001: ldfld int32 valuetype ILCorpus.Sample.RepresentativeType`1/'d__17'::'<>1__state' + IL_0006: stloc.0 + .try + { + IL_0007: ldloc.0 + IL_0008: brfalse.s IL_0041 + + IL_000a: call valuetype [System.Runtime]System.Runtime.CompilerServices.YieldAwaitable [System.Runtime]System.Threading.Tasks.Task::Yield() + IL_000f: stloc.3 + IL_0010: ldloca.s 3 + IL_0012: call instance valuetype [System.Runtime]System.Runtime.CompilerServices.YieldAwaitable/YieldAwaiter [System.Runtime]System.Runtime.CompilerServices.YieldAwaitable::GetAwaiter() + IL_0017: stloc.2 + IL_0018: ldloca.s 2 + IL_001a: call instance bool [System.Runtime]System.Runtime.CompilerServices.YieldAwaitable/YieldAwaiter::get_IsCompleted() + IL_001f: brtrue.s IL_005d + + IL_0021: ldarg.0 + IL_0022: ldc.i4.0 + IL_0023: dup + IL_0024: stloc.0 + IL_0025: stfld int32 valuetype ILCorpus.Sample.RepresentativeType`1/'d__17'::'<>1__state' + IL_002a: ldarg.0 + IL_002b: ldloc.2 + IL_002c: stfld valuetype [System.Runtime]System.Runtime.CompilerServices.YieldAwaitable/YieldAwaiter valuetype ILCorpus.Sample.RepresentativeType`1/'d__17'::'<>u__1' + IL_0031: ldarg.0 + IL_0032: ldflda valuetype [System.Runtime]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1 valuetype ILCorpus.Sample.RepresentativeType`1/'d__17'::'<>t__builder' + IL_0037: ldloca.s 2 + IL_0039: ldarg.0 + IL_003a: call instance void valuetype [System.Runtime]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1::AwaitUnsafeOnCompletedd__17'>(!!0&, !!1&) + IL_003f: leave.s IL_009c + + IL_0041: ldarg.0 + IL_0042: ldfld valuetype [System.Runtime]System.Runtime.CompilerServices.YieldAwaitable/YieldAwaiter valuetype ILCorpus.Sample.RepresentativeType`1/'d__17'::'<>u__1' + IL_0047: stloc.2 + IL_0048: ldarg.0 + IL_0049: ldflda valuetype [System.Runtime]System.Runtime.CompilerServices.YieldAwaitable/YieldAwaiter valuetype ILCorpus.Sample.RepresentativeType`1/'d__17'::'<>u__1' + IL_004e: initobj [System.Runtime]System.Runtime.CompilerServices.YieldAwaitable/YieldAwaiter + IL_0054: ldarg.0 + IL_0055: ldc.i4.m1 + IL_0056: dup + IL_0057: stloc.0 + IL_0058: stfld int32 valuetype ILCorpus.Sample.RepresentativeType`1/'d__17'::'<>1__state' + + IL_005d: ldloca.s 2 + IL_005f: call instance void [System.Runtime]System.Runtime.CompilerServices.YieldAwaitable/YieldAwaiter::GetResult() + IL_0064: ldarg.0 + IL_0065: ldfld int32 valuetype ILCorpus.Sample.RepresentativeType`1/'d__17'::'value' + IL_006a: ldc.i4.7 + IL_006b: add + IL_006c: stloc.1 + IL_006d: leave.s IL_0088 + } // end .try + catch [System.Runtime]System.Exception + { + IL_006f: stloc.s 4 + IL_0071: ldarg.0 + IL_0072: ldc.i4.s -2 + IL_0074: stfld int32 valuetype ILCorpus.Sample.RepresentativeType`1/'d__17'::'<>1__state' + IL_0079: ldarg.0 + IL_007a: ldflda valuetype [System.Runtime]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1 valuetype ILCorpus.Sample.RepresentativeType`1/'d__17'::'<>t__builder' + IL_007f: ldloc.s 4 + IL_0081: call instance void valuetype [System.Runtime]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1::SetException(class [System.Runtime]System.Exception) + IL_0086: leave.s IL_009c + } // end handler + + IL_0088: ldarg.0 + IL_0089: ldc.i4.s -2 + IL_008b: stfld int32 valuetype ILCorpus.Sample.RepresentativeType`1/'d__17'::'<>1__state' + IL_0090: ldarg.0 + IL_0091: ldflda valuetype [System.Runtime]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1 valuetype ILCorpus.Sample.RepresentativeType`1/'d__17'::'<>t__builder' + IL_0096: ldloc.1 + IL_0097: call instance void valuetype [System.Runtime]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1::SetResult(!0) + + IL_009c: ret + } // end of method 'd__17'::MoveNext + + .method private final hidebysig newslot virtual + instance void SetStateMachine ( + class [System.Runtime]System.Runtime.CompilerServices.IAsyncStateMachine stateMachine + ) cil managed + { + .custom instance void [System.Runtime]System.Diagnostics.DebuggerHiddenAttribute::.ctor() = ( + 01 00 00 00 + ) + .override method instance void [System.Runtime]System.Runtime.CompilerServices.IAsyncStateMachine::SetStateMachine(class [System.Runtime]System.Runtime.CompilerServices.IAsyncStateMachine) + // Method begins at RVA 0x228c + // Header size: 1 + // Code size: 13 (0xd) + .maxstack 8 + + IL_0000: ldarg.0 + IL_0001: ldflda valuetype [System.Runtime]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1 valuetype ILCorpus.Sample.RepresentativeType`1/'d__17'::'<>t__builder' + IL_0006: ldarg.1 + IL_0007: call instance void valuetype [System.Runtime]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1::SetStateMachine(class [System.Runtime]System.Runtime.CompilerServices.IAsyncStateMachine) + IL_000c: ret + } // end of method 'd__17'::SetStateMachine + + } // end of class d__17 + + .class nested private auto ansi sealed beforefieldinit 'd__18' + extends [System.Runtime]System.Object + implements class [System.Runtime]System.Collections.Generic.IEnumerable`1, + [System.Runtime]System.Collections.IEnumerable, + class [System.Runtime]System.Collections.Generic.IEnumerator`1, + [System.Runtime]System.Collections.IEnumerator, + [System.Runtime]System.IDisposable + { + .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = ( + 01 00 00 00 + ) + .interfaceimpl type class [System.Runtime]System.Collections.Generic.IEnumerable`1 + .custom instance void [System.Runtime]System.Runtime.CompilerServices.NullableAttribute::.ctor(uint8) = ( + 01 00 00 00 00 + ) + + .interfaceimpl type [System.Runtime]System.Collections.IEnumerable + .custom instance void [System.Runtime]System.Runtime.CompilerServices.NullableAttribute::.ctor(uint8) = ( + 01 00 00 00 00 + ) + + .interfaceimpl type class [System.Runtime]System.Collections.Generic.IEnumerator`1 + .custom instance void [System.Runtime]System.Runtime.CompilerServices.NullableAttribute::.ctor(uint8) = ( + 01 00 00 00 00 + ) + + .interfaceimpl type [System.Runtime]System.Collections.IEnumerator + .custom instance void [System.Runtime]System.Runtime.CompilerServices.NullableAttribute::.ctor(uint8) = ( + 01 00 00 00 00 + ) + + .interfaceimpl type [System.Runtime]System.IDisposable + .custom instance void [System.Runtime]System.Runtime.CompilerServices.NullableAttribute::.ctor(uint8) = ( + 01 00 00 00 00 + ) + + // Fields + .field private int32 '<>1__state' + .field private int32 '<>2__current' + .field private int32 '<>l__initialThreadId' + .field private int32 maximum + .field public int32 '<>3__maximum' + .field private int32 '5__2' + + // Methods + .method public hidebysig specialname rtspecialname + instance void .ctor ( + int32 '<>1__state' + ) cil managed + { + .custom instance void [System.Runtime]System.Diagnostics.DebuggerHiddenAttribute::.ctor() = ( + 01 00 00 00 + ) + // Method begins at RVA 0x229a + // Header size: 1 + // Code size: 25 (0x19) + .maxstack 8 + + IL_0000: ldarg.0 + IL_0001: call instance void [System.Runtime]System.Object::.ctor() + IL_0006: ldarg.0 + IL_0007: ldarg.1 + IL_0008: stfld int32 class ILCorpus.Sample.RepresentativeType`1/'d__18'::'<>1__state' + IL_000d: ldarg.0 + IL_000e: call int32 [System.Runtime]System.Environment::get_CurrentManagedThreadId() + IL_0013: stfld int32 class ILCorpus.Sample.RepresentativeType`1/'d__18'::'<>l__initialThreadId' + IL_0018: ret + } // end of method 'd__18'::.ctor + + .method private final hidebysig newslot virtual + instance void System.IDisposable.Dispose () cil managed + { + .custom instance void [System.Runtime]System.Diagnostics.DebuggerHiddenAttribute::.ctor() = ( + 01 00 00 00 + ) + .override method instance void [System.Runtime]System.IDisposable::Dispose() + // Method begins at RVA 0x22b4 + // Header size: 1 + // Code size: 1 (0x1) + .maxstack 8 + + IL_0000: ret + } // end of method 'd__18'::System.IDisposable.Dispose + + .method private final hidebysig newslot virtual + instance bool MoveNext () cil managed + { + .override method instance bool [System.Runtime]System.Collections.IEnumerator::MoveNext() + // Method begins at RVA 0x22b8 + // Header size: 12 + // Code size: 92 (0x5c) + .maxstack 3 + .locals init ( + [0] int32, + [1] int32 + ) + + IL_0000: ldarg.0 + IL_0001: ldfld int32 class ILCorpus.Sample.RepresentativeType`1/'d__18'::'<>1__state' + IL_0006: stloc.0 + IL_0007: ldloc.0 + IL_0008: brfalse.s IL_0010 + + IL_000a: ldloc.0 + IL_000b: ldc.i4.1 + IL_000c: beq.s IL_0035 + + IL_000e: ldc.i4.0 + IL_000f: ret + + IL_0010: ldarg.0 + IL_0011: ldc.i4.m1 + IL_0012: stfld int32 class ILCorpus.Sample.RepresentativeType`1/'d__18'::'<>1__state' + IL_0017: ldarg.0 + IL_0018: ldc.i4.0 + IL_0019: stfld int32 class ILCorpus.Sample.RepresentativeType`1/'d__18'::'5__2' + IL_001e: br.s IL_004c + + IL_0020: ldarg.0 + IL_0021: ldarg.0 + IL_0022: ldfld int32 class ILCorpus.Sample.RepresentativeType`1/'d__18'::'5__2' + IL_0027: stfld int32 class ILCorpus.Sample.RepresentativeType`1/'d__18'::'<>2__current' + IL_002c: ldarg.0 + IL_002d: ldc.i4.1 + IL_002e: stfld int32 class ILCorpus.Sample.RepresentativeType`1/'d__18'::'<>1__state' + IL_0033: ldc.i4.1 + IL_0034: ret + + IL_0035: ldarg.0 + IL_0036: ldc.i4.m1 + IL_0037: stfld int32 class ILCorpus.Sample.RepresentativeType`1/'d__18'::'<>1__state' + IL_003c: ldarg.0 + IL_003d: ldfld int32 class ILCorpus.Sample.RepresentativeType`1/'d__18'::'5__2' + IL_0042: stloc.1 + IL_0043: ldarg.0 + IL_0044: ldloc.1 + IL_0045: ldc.i4.1 + IL_0046: add + IL_0047: stfld int32 class ILCorpus.Sample.RepresentativeType`1/'d__18'::'5__2' + + IL_004c: ldarg.0 + IL_004d: ldfld int32 class ILCorpus.Sample.RepresentativeType`1/'d__18'::'5__2' + IL_0052: ldarg.0 + IL_0053: ldfld int32 class ILCorpus.Sample.RepresentativeType`1/'d__18'::maximum + IL_0058: blt.s IL_0020 + + IL_005a: ldc.i4.0 + IL_005b: ret + } // end of method 'd__18'::MoveNext + + .method private final hidebysig specialname newslot virtual + instance int32 'System.Collections.Generic.IEnumerator.get_Current' () cil managed + { + .custom instance void [System.Runtime]System.Diagnostics.DebuggerHiddenAttribute::.ctor() = ( + 01 00 00 00 + ) + .override method instance !0 class [System.Runtime]System.Collections.Generic.IEnumerator`1::get_Current() + // Method begins at RVA 0x2320 + // Header size: 1 + // Code size: 7 (0x7) + .maxstack 8 + + IL_0000: ldarg.0 + IL_0001: ldfld int32 class ILCorpus.Sample.RepresentativeType`1/'d__18'::'<>2__current' + IL_0006: ret + } // end of method 'd__18'::'System.Collections.Generic.IEnumerator.get_Current' + + .method private final hidebysig newslot virtual + instance void System.Collections.IEnumerator.Reset () cil managed + { + .custom instance void [System.Runtime]System.Diagnostics.DebuggerHiddenAttribute::.ctor() = ( + 01 00 00 00 + ) + .override method instance void [System.Runtime]System.Collections.IEnumerator::Reset() + // Method begins at RVA 0x2328 + // Header size: 1 + // Code size: 6 (0x6) + .maxstack 8 + + IL_0000: newobj instance void [System.Runtime]System.NotSupportedException::.ctor() + IL_0005: throw + } // end of method 'd__18'::System.Collections.IEnumerator.Reset + + .method private final hidebysig specialname newslot virtual + instance object System.Collections.IEnumerator.get_Current () cil managed + { + .custom instance void [System.Runtime]System.Diagnostics.DebuggerHiddenAttribute::.ctor() = ( + 01 00 00 00 + ) + .override method instance object [System.Runtime]System.Collections.IEnumerator::get_Current() + .param [0] + .custom instance void [System.Runtime]System.Runtime.CompilerServices.NullableAttribute::.ctor(uint8) = ( + 01 00 00 00 00 + ) + // Method begins at RVA 0x232f + // Header size: 1 + // Code size: 12 (0xc) + .maxstack 8 + + IL_0000: ldarg.0 + IL_0001: ldfld int32 class ILCorpus.Sample.RepresentativeType`1/'d__18'::'<>2__current' + IL_0006: box [System.Runtime]System.Int32 + IL_000b: ret + } // end of method 'd__18'::System.Collections.IEnumerator.get_Current + + .method private final hidebysig newslot virtual + instance class [System.Runtime]System.Collections.Generic.IEnumerator`1 'System.Collections.Generic.IEnumerable.GetEnumerator' () cil managed + { + .custom instance void [System.Runtime]System.Diagnostics.DebuggerHiddenAttribute::.ctor() = ( + 01 00 00 00 + ) + .override method instance class [System.Runtime]System.Collections.Generic.IEnumerator`1 class [System.Runtime]System.Collections.Generic.IEnumerable`1::GetEnumerator() + // Method begins at RVA 0x233c + // Header size: 12 + // Code size: 55 (0x37) + .maxstack 2 + .locals init ( + [0] class ILCorpus.Sample.RepresentativeType`1/'d__18' + ) + + IL_0000: ldarg.0 + IL_0001: ldfld int32 class ILCorpus.Sample.RepresentativeType`1/'d__18'::'<>1__state' + IL_0006: ldc.i4.s -2 + IL_0008: bne.un.s IL_0022 + + IL_000a: ldarg.0 + IL_000b: ldfld int32 class ILCorpus.Sample.RepresentativeType`1/'d__18'::'<>l__initialThreadId' + IL_0010: call int32 [System.Runtime]System.Environment::get_CurrentManagedThreadId() + IL_0015: bne.un.s IL_0022 + + IL_0017: ldarg.0 + IL_0018: ldc.i4.0 + IL_0019: stfld int32 class ILCorpus.Sample.RepresentativeType`1/'d__18'::'<>1__state' + IL_001e: ldarg.0 + IL_001f: stloc.0 + IL_0020: br.s IL_0029 + + IL_0022: ldc.i4.0 + IL_0023: newobj instance void class ILCorpus.Sample.RepresentativeType`1/'d__18'::.ctor(int32) + IL_0028: stloc.0 + + IL_0029: ldloc.0 + IL_002a: ldarg.0 + IL_002b: ldfld int32 class ILCorpus.Sample.RepresentativeType`1/'d__18'::'<>3__maximum' + IL_0030: stfld int32 class ILCorpus.Sample.RepresentativeType`1/'d__18'::maximum + IL_0035: ldloc.0 + IL_0036: ret + } // end of method 'd__18'::'System.Collections.Generic.IEnumerable.GetEnumerator' + + .method private final hidebysig newslot virtual + instance class [System.Runtime]System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator () cil managed + { + .custom instance void [System.Runtime]System.Diagnostics.DebuggerHiddenAttribute::.ctor() = ( + 01 00 00 00 + ) + .override method instance class [System.Runtime]System.Collections.IEnumerator [System.Runtime]System.Collections.IEnumerable::GetEnumerator() + // Method begins at RVA 0x237f + // Header size: 1 + // Code size: 7 (0x7) + .maxstack 8 + + IL_0000: ldarg.0 + IL_0001: call instance class [System.Runtime]System.Collections.Generic.IEnumerator`1 class ILCorpus.Sample.RepresentativeType`1/'d__18'::'System.Collections.Generic.IEnumerable.GetEnumerator'() + IL_0006: ret + } // end of method 'd__18'::System.Collections.IEnumerable.GetEnumerator + + // Properties + .property instance int32 'System.Collections.Generic.IEnumerator.Current'() + { + .get instance int32 ILCorpus.Sample.RepresentativeType`1/'d__18'::'System.Collections.Generic.IEnumerator.get_Current'() + } + .property instance object System.Collections.IEnumerator.Current() + { + .get instance object ILCorpus.Sample.RepresentativeType`1/'d__18'::System.Collections.IEnumerator.get_Current() + } + + } // end of class d__18 + + + // Fields + .field public static literal int32 DefaultValue = int32(7) + .field public static initonly string StaticValue + .field private !T _value + .field private string 'k__BackingField' + .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = ( + 01 00 00 00 + ) + .field private class [System.Runtime]System.EventHandler Changed + .custom instance void [System.Runtime]System.Runtime.CompilerServices.NullableAttribute::.ctor(uint8) = ( + 01 00 02 00 00 + ) + .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = ( + 01 00 00 00 + ) + + // Methods + .method private hidebysig specialname rtspecialname static + void .cctor () cil managed + { + // Method begins at RVA 0x2050 + // Header size: 1 + // Code size: 11 (0xb) + .maxstack 8 + + IL_0000: ldstr "initialized" + IL_0005: stsfld string class ILCorpus.Sample.RepresentativeType`1::StaticValue + IL_000a: ret + } // end of method RepresentativeType`1::.cctor + + .method public hidebysig specialname rtspecialname + instance void .ctor () cil managed + { + // Method begins at RVA 0x205c + // Header size: 1 + // Code size: 29 (0x1d) + .maxstack 8 + + IL_0000: ldarg.0 + IL_0001: call instance void [System.Runtime]System.Object::.ctor() + IL_0006: ldarg.0 + IL_0007: call !!0 [System.Runtime]System.Activator::CreateInstance() + IL_000c: stfld !0 class ILCorpus.Sample.RepresentativeType`1::_value + IL_0011: ldarg.0 + IL_0012: ldsfld string [System.Runtime]System.String::Empty + IL_0017: call instance void class ILCorpus.Sample.RepresentativeType`1::set_Name(string) + IL_001c: ret + } // end of method RepresentativeType`1::.ctor + + .method public hidebysig specialname + instance string get_Name () cil managed + { + .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = ( + 01 00 00 00 + ) + // Method begins at RVA 0x207a + // Header size: 1 + // Code size: 7 (0x7) + .maxstack 8 + + IL_0000: ldarg.0 + IL_0001: ldfld string class ILCorpus.Sample.RepresentativeType`1::'k__BackingField' + IL_0006: ret + } // end of method RepresentativeType`1::get_Name + + .method public hidebysig specialname + instance void set_Name ( + string 'value' + ) cil managed + { + .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = ( + 01 00 00 00 + ) + // Method begins at RVA 0x2082 + // Header size: 1 + // Code size: 8 (0x8) + .maxstack 8 + + IL_0000: ldarg.0 + IL_0001: ldarg.1 + IL_0002: stfld string class ILCorpus.Sample.RepresentativeType`1::'k__BackingField' + IL_0007: ret + } // end of method RepresentativeType`1::set_Name + + .method public hidebysig specialname + instance !T get_Value () cil managed + { + // Method begins at RVA 0x208b + // Header size: 1 + // Code size: 7 (0x7) + .maxstack 8 + + IL_0000: ldarg.0 + IL_0001: ldfld !0 class ILCorpus.Sample.RepresentativeType`1::_value + IL_0006: ret + } // end of method RepresentativeType`1::get_Value + + .method public hidebysig specialname + instance void set_Value ( + !T 'value' + ) cil managed + { + // Method begins at RVA 0x2093 + // Header size: 1 + // Code size: 30 (0x1e) + .maxstack 8 + + IL_0000: ldarg.0 + IL_0001: ldarg.1 + IL_0002: stfld !0 class ILCorpus.Sample.RepresentativeType`1::_value + IL_0007: ldarg.0 + IL_0008: ldfld class [System.Runtime]System.EventHandler class ILCorpus.Sample.RepresentativeType`1::Changed + IL_000d: dup + IL_000e: brtrue.s IL_0012 + + IL_0010: pop + IL_0011: ret + + IL_0012: ldarg.0 + IL_0013: ldsfld class [System.Runtime]System.EventArgs [System.Runtime]System.EventArgs::Empty + IL_0018: callvirt instance void [System.Runtime]System.EventHandler::Invoke(object, class [System.Runtime]System.EventArgs) + IL_001d: ret + } // end of method RepresentativeType`1::set_Value + + .method public hidebysig specialname + instance void add_Changed ( + class [System.Runtime]System.EventHandler 'value' + ) cil managed + { + .custom instance void [System.Runtime]System.Runtime.CompilerServices.NullableContextAttribute::.ctor(uint8) = ( + 01 00 02 00 00 + ) + .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = ( + 01 00 00 00 + ) + // Method begins at RVA 0x20b4 + // Header size: 12 + // Code size: 41 (0x29) + .maxstack 3 + .locals init ( + [0] class [System.Runtime]System.EventHandler, + [1] class [System.Runtime]System.EventHandler, + [2] class [System.Runtime]System.EventHandler + ) + + IL_0000: ldarg.0 + IL_0001: ldfld class [System.Runtime]System.EventHandler class ILCorpus.Sample.RepresentativeType`1::Changed + IL_0006: stloc.0 + // loop start (head: IL_0007) + IL_0007: ldloc.0 + IL_0008: stloc.1 + IL_0009: ldloc.1 + IL_000a: ldarg.1 + IL_000b: call class [System.Runtime]System.Delegate [System.Runtime]System.Delegate::Combine(class [System.Runtime]System.Delegate, class [System.Runtime]System.Delegate) + IL_0010: castclass [System.Runtime]System.EventHandler + IL_0015: stloc.2 + IL_0016: ldarg.0 + IL_0017: ldflda class [System.Runtime]System.EventHandler class ILCorpus.Sample.RepresentativeType`1::Changed + IL_001c: ldloc.2 + IL_001d: ldloc.1 + IL_001e: call !!0 [System.Threading]System.Threading.Interlocked::CompareExchange(!!0&, !!0, !!0) + IL_0023: stloc.0 + IL_0024: ldloc.0 + IL_0025: ldloc.1 + IL_0026: bne.un.s IL_0007 + // end loop + IL_0028: ret + } // end of method RepresentativeType`1::add_Changed + + .method public hidebysig specialname + instance void remove_Changed ( + class [System.Runtime]System.EventHandler 'value' + ) cil managed + { + .custom instance void [System.Runtime]System.Runtime.CompilerServices.NullableContextAttribute::.ctor(uint8) = ( + 01 00 02 00 00 + ) + .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = ( + 01 00 00 00 + ) + // Method begins at RVA 0x20ec + // Header size: 12 + // Code size: 41 (0x29) + .maxstack 3 + .locals init ( + [0] class [System.Runtime]System.EventHandler, + [1] class [System.Runtime]System.EventHandler, + [2] class [System.Runtime]System.EventHandler + ) + + IL_0000: ldarg.0 + IL_0001: ldfld class [System.Runtime]System.EventHandler class ILCorpus.Sample.RepresentativeType`1::Changed + IL_0006: stloc.0 + // loop start (head: IL_0007) + IL_0007: ldloc.0 + IL_0008: stloc.1 + IL_0009: ldloc.1 + IL_000a: ldarg.1 + IL_000b: call class [System.Runtime]System.Delegate [System.Runtime]System.Delegate::Remove(class [System.Runtime]System.Delegate, class [System.Runtime]System.Delegate) + IL_0010: castclass [System.Runtime]System.EventHandler + IL_0015: stloc.2 + IL_0016: ldarg.0 + IL_0017: ldflda class [System.Runtime]System.EventHandler class ILCorpus.Sample.RepresentativeType`1::Changed + IL_001c: ldloc.2 + IL_001d: ldloc.1 + IL_001e: call !!0 [System.Threading]System.Threading.Interlocked::CompareExchange(!!0&, !!0, !!0) + IL_0023: stloc.0 + IL_0024: ldloc.0 + IL_0025: ldloc.1 + IL_0026: bne.un.s IL_0007 + // end loop + IL_0028: ret + } // end of method RepresentativeType`1::remove_Changed + + .method public hidebysig + instance int32 Add ( + int32 left, + int32 right + ) cil managed + { + // Method begins at RVA 0x2121 + // Header size: 1 + // Code size: 4 (0x4) + .maxstack 8 + + IL_0000: ldarg.1 + IL_0001: ldarg.2 + IL_0002: add + IL_0003: ret + } // end of method RepresentativeType`1::Add + + .method public hidebysig + instance !!TResult Convert ( + class [System.Runtime]System.Func`2 converter + ) cil managed + { + .param type TResult + .custom instance void [System.Runtime]System.Runtime.CompilerServices.NullableAttribute::.ctor(uint8) = ( + 01 00 02 00 00 + ) + // Method begins at RVA 0x2126 + // Header size: 1 + // Code size: 13 (0xd) + .maxstack 8 + + IL_0000: ldarg.1 + IL_0001: ldarg.0 + IL_0002: ldfld !0 class ILCorpus.Sample.RepresentativeType`1::_value + IL_0007: callvirt instance !1 class [System.Runtime]System.Func`2::Invoke(!0) + IL_000c: ret + } // end of method RepresentativeType`1::Convert + + .method public hidebysig + instance class [System.Runtime]System.Threading.Tasks.Task`1 ComputeAsync ( + int32 'value' + ) cil managed + { + .custom instance void [System.Runtime]System.Runtime.CompilerServices.AsyncStateMachineAttribute::.ctor(class [System.Runtime]System.Type) = ( + 01 00 38 49 4c 43 6f 72 70 75 73 2e 53 61 6d 70 + 6c 65 2e 52 65 70 72 65 73 65 6e 74 61 74 69 76 + 65 54 79 70 65 60 31 2b 3c 43 6f 6d 70 75 74 65 + 41 73 79 6e 63 3e 64 5f 5f 31 37 00 00 + ) + // Method begins at RVA 0x2134 + // Header size: 12 + // Code size: 55 (0x37) + .maxstack 2 + .locals init ( + [0] valuetype ILCorpus.Sample.RepresentativeType`1/'d__17' + ) + + IL_0000: ldloca.s 0 + IL_0002: call valuetype [System.Runtime]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1 valuetype [System.Runtime]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1::Create() + IL_0007: stfld valuetype [System.Runtime]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1 valuetype ILCorpus.Sample.RepresentativeType`1/'d__17'::'<>t__builder' + IL_000c: ldloca.s 0 + IL_000e: ldarg.1 + IL_000f: stfld int32 valuetype ILCorpus.Sample.RepresentativeType`1/'d__17'::'value' + IL_0014: ldloca.s 0 + IL_0016: ldc.i4.m1 + IL_0017: stfld int32 valuetype ILCorpus.Sample.RepresentativeType`1/'d__17'::'<>1__state' + IL_001c: ldloca.s 0 + IL_001e: ldflda valuetype [System.Runtime]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1 valuetype ILCorpus.Sample.RepresentativeType`1/'d__17'::'<>t__builder' + IL_0023: ldloca.s 0 + IL_0025: call instance void valuetype [System.Runtime]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1::Startd__17'>(!!0&) + IL_002a: ldloca.s 0 + IL_002c: ldflda valuetype [System.Runtime]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1 valuetype ILCorpus.Sample.RepresentativeType`1/'d__17'::'<>t__builder' + IL_0031: call instance class [System.Runtime]System.Threading.Tasks.Task`1 valuetype [System.Runtime]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1::get_Task() + IL_0036: ret + } // end of method RepresentativeType`1::ComputeAsync + + .method public hidebysig + instance class [System.Runtime]System.Collections.Generic.IEnumerable`1 CountTo ( + int32 maximum + ) cil managed + { + .custom instance void [System.Runtime]System.Runtime.CompilerServices.IteratorStateMachineAttribute::.ctor(class [System.Runtime]System.Type) = ( + 01 00 33 49 4c 43 6f 72 70 75 73 2e 53 61 6d 70 + 6c 65 2e 52 65 70 72 65 73 65 6e 74 61 74 69 76 + 65 54 79 70 65 60 31 2b 3c 43 6f 75 6e 74 54 6f + 3e 64 5f 5f 31 38 00 00 + ) + // Method begins at RVA 0x2177 + // Header size: 1 + // Code size: 15 (0xf) + .maxstack 8 + + IL_0000: ldc.i4.s -2 + IL_0002: newobj instance void class ILCorpus.Sample.RepresentativeType`1/'d__18'::.ctor(int32) + IL_0007: dup + IL_0008: ldarg.1 + IL_0009: stfld int32 class ILCorpus.Sample.RepresentativeType`1/'d__18'::'<>3__maximum' + IL_000e: ret + } // end of method RepresentativeType`1::CountTo + + .method public hidebysig + instance class [System.Runtime]System.Func`2 CreateAdder ( + int32 offset + ) cil managed + { + // Method begins at RVA 0x2187 + // Header size: 1 + // Code size: 24 (0x18) + .maxstack 8 + + IL_0000: newobj instance void class ILCorpus.Sample.RepresentativeType`1/'<>c__DisplayClass19_0'::.ctor() + IL_0005: dup + IL_0006: ldarg.1 + IL_0007: stfld int32 class ILCorpus.Sample.RepresentativeType`1/'<>c__DisplayClass19_0'::offset + IL_000c: ldftn instance int32 class ILCorpus.Sample.RepresentativeType`1/'<>c__DisplayClass19_0'::'b__0'(int32) + IL_0012: newobj instance void class [System.Runtime]System.Func`2::.ctor(object, native int) + IL_0017: ret + } // end of method RepresentativeType`1::CreateAdder + + // Events + .event [System.Runtime]System.EventHandler Changed + { + .custom instance void [System.Runtime]System.Runtime.CompilerServices.NullableAttribute::.ctor(uint8) = ( + 01 00 02 00 00 + ) + .addon instance void ILCorpus.Sample.RepresentativeType`1::add_Changed(class [System.Runtime]System.EventHandler) + .removeon instance void ILCorpus.Sample.RepresentativeType`1::remove_Changed(class [System.Runtime]System.EventHandler) + } + + + // Properties + .property instance string Name() + { + .get instance string ILCorpus.Sample.RepresentativeType`1::get_Name() + .set instance void ILCorpus.Sample.RepresentativeType`1::set_Name(string) + } + .property instance !T Value() + { + .get instance !0 ILCorpus.Sample.RepresentativeType`1::get_Value() + .set instance void ILCorpus.Sample.RepresentativeType`1::set_Value(!0) + } + +} // end of class ILCorpus.Sample.RepresentativeType`1 + +.class interface public auto ansi abstract import beforefieldinit ILCorpus.Sample.ICorpusComContract +{ + .custom instance void [System.Runtime]System.Runtime.CompilerServices.NullableContextAttribute::.ctor(uint8) = ( + 01 00 01 00 00 + ) + .custom instance void [System.Runtime]System.Runtime.InteropServices.ComVisibleAttribute::.ctor(bool) = ( + 01 00 01 00 00 + ) + .custom instance void [System.Runtime.InteropServices]System.Runtime.InteropServices.GuidAttribute::.ctor(string) = ( + 01 00 24 35 38 38 38 33 30 34 35 2d 39 32 62 34 + 2d 34 63 39 66 2d 61 34 32 61 2d 33 65 32 35 61 + 39 62 39 30 66 37 38 00 00 + ) + .custom instance void [System.Runtime.InteropServices]System.Runtime.InteropServices.InterfaceTypeAttribute::.ctor(valuetype [System.Runtime.InteropServices]System.Runtime.InteropServices.ComInterfaceType) = ( + 01 00 00 00 00 00 00 00 + ) + // Methods + .method public hidebysig newslot abstract virtual + instance string Echo ( + string marshal(bstr) 'value' + ) cil managed + { + .custom instance void [System.Runtime.InteropServices]System.Runtime.InteropServices.DispIdAttribute::.ctor(int32) = ( + 01 00 01 00 00 00 00 00 + ) + } // end of method ICorpusComContract::Echo + + .method public hidebysig specialname newslot abstract virtual + instance int32 get_Number () cil managed + { + } // end of method ICorpusComContract::get_Number + + .method public hidebysig specialname newslot abstract virtual + instance void set_Number ( + int32 'value' + ) cil managed + { + } // end of method ICorpusComContract::set_Number + + // Properties + .property instance int32 Number() + { + .custom instance void [System.Runtime.InteropServices]System.Runtime.InteropServices.DispIdAttribute::.ctor(int32) = ( + 01 00 02 00 00 00 00 00 + ) + .get instance int32 ILCorpus.Sample.ICorpusComContract::get_Number() + .set instance void ILCorpus.Sample.ICorpusComContract::set_Number(int32) + } + +} // end of class ILCorpus.Sample.ICorpusComContract + +.class public auto ansi sealed beforefieldinit ILCorpus.Sample.CorpusComClass + extends [System.Runtime]System.Object + implements ILCorpus.Sample.ICorpusComContract +{ + .custom instance void [System.Runtime]System.Runtime.InteropServices.ComVisibleAttribute::.ctor(bool) = ( + 01 00 01 00 00 + ) + .custom instance void [System.Runtime.InteropServices]System.Runtime.InteropServices.GuidAttribute::.ctor(string) = ( + 01 00 24 34 33 32 33 39 38 65 31 2d 33 33 34 66 + 2d 34 35 61 66 2d 38 33 38 37 2d 38 32 66 33 34 + 62 38 61 63 35 32 36 00 00 + ) + .custom instance void [System.Runtime.InteropServices]System.Runtime.InteropServices.ClassInterfaceAttribute::.ctor(valuetype [System.Runtime.InteropServices]System.Runtime.InteropServices.ClassInterfaceType) = ( + 01 00 00 00 00 00 00 00 + ) + // Fields + .field private int32 'k__BackingField' + .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = ( + 01 00 00 00 + ) + + // Methods + .method public final hidebysig specialname newslot virtual + instance int32 get_Number () cil managed + { + .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = ( + 01 00 00 00 + ) + // Method begins at RVA 0x21a0 + // Header size: 1 + // Code size: 7 (0x7) + .maxstack 8 + + IL_0000: ldarg.0 + IL_0001: ldfld int32 ILCorpus.Sample.CorpusComClass::'k__BackingField' + IL_0006: ret + } // end of method CorpusComClass::get_Number + + .method public final hidebysig specialname newslot virtual + instance void set_Number ( + int32 'value' + ) cil managed + { + .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = ( + 01 00 00 00 + ) + // Method begins at RVA 0x21a8 + // Header size: 1 + // Code size: 8 (0x8) + .maxstack 8 + + IL_0000: ldarg.0 + IL_0001: ldarg.1 + IL_0002: stfld int32 ILCorpus.Sample.CorpusComClass::'k__BackingField' + IL_0007: ret + } // end of method CorpusComClass::set_Number + + .method public final hidebysig newslot virtual + instance string Echo ( + string 'value' + ) cil managed + { + .custom instance void [System.Runtime]System.Runtime.CompilerServices.NullableContextAttribute::.ctor(uint8) = ( + 01 00 01 00 00 + ) + // Method begins at RVA 0x21b1 + // Header size: 1 + // Code size: 2 (0x2) + .maxstack 8 + + IL_0000: ldarg.1 + IL_0001: ret + } // end of method CorpusComClass::Echo + + .method public hidebysig specialname rtspecialname + instance void .ctor () cil managed + { + // Method begins at RVA 0x21b4 + // Header size: 1 + // Code size: 7 (0x7) + .maxstack 8 + + IL_0000: ldarg.0 + IL_0001: call instance void [System.Runtime]System.Object::.ctor() + IL_0006: ret + } // end of method CorpusComClass::.ctor + + // Properties + .property instance int32 Number() + { + .get instance int32 ILCorpus.Sample.CorpusComClass::get_Number() + .set instance void ILCorpus.Sample.CorpusComClass::set_Number(int32) + } + +} // end of class ILCorpus.Sample.CorpusComClass + diff --git a/FolderDiffIL4DotNet.Tests/Fixtures/ILCorpus/regenerate.ps1 b/FolderDiffIL4DotNet.Tests/Fixtures/ILCorpus/regenerate.ps1 new file mode 100644 index 00000000..288a8694 --- /dev/null +++ b/FolderDiffIL4DotNet.Tests/Fixtures/ILCorpus/regenerate.ps1 @@ -0,0 +1,48 @@ +[CmdletBinding()] +param( + [string] $Configuration = "Release" +) + +$ErrorActionPreference = "Stop" +$fixtureRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +$sourceRoot = Join-Path $fixtureRoot "Source" +$projectPath = Join-Path $sourceRoot "ILCorpus.Sample.csproj" +$assemblyPath = Join-Path $sourceRoot "bin/$Configuration/net8.0/ILCorpus.Sample.dll" +$dotnetIldasmOutput = Join-Path $fixtureRoot "dotnet-ildasm-0.12.2.il" +$ilspyOutput = Join-Path $fixtureRoot "ilspycmd-9.1.0.7988.il" + +$previousRollForward = $env:DOTNET_ROLL_FORWARD +try { + $env:DOTNET_ROLL_FORWARD = "Major" + + $dotnetIldasmVersion = (& dotnet-ildasm --version | Out-String) + if ($dotnetIldasmVersion -notmatch "0\.12\.2\.0") { + throw "Expected dotnet-ildasm 0.12.2.0, but got: $dotnetIldasmVersion" + } + + $ilspyVersion = (& ilspycmd --version | Out-String) + if ($ilspyVersion -notmatch "9\.1\.0\.7988") { + throw "Expected ilspycmd 9.1.0.7988, but got: $ilspyVersion" + } + + dotnet build $projectPath --configuration $Configuration --nologo + if ($LASTEXITCODE -ne 0) { + throw "Failed to build the IL corpus sample assembly." + } + + dotnet-ildasm $assemblyPath --output $dotnetIldasmOutput --force + if ($LASTEXITCODE -ne 0) { + throw "dotnet-ildasm failed." + } + + & ilspycmd --ilcode --disable-updatecheck $assemblyPath | + Set-Content -LiteralPath $ilspyOutput -Encoding utf8 + if ($LASTEXITCODE -ne 0) { + throw "ilspycmd failed." + } +} +finally { + $env:DOTNET_ROLL_FORWARD = $previousRollForward +} + +Write-Host "Regenerated IL corpus fixtures in $fixtureRoot" diff --git a/FolderDiffIL4DotNet.Tests/FolderDiffIL4DotNet.Tests.csproj b/FolderDiffIL4DotNet.Tests/FolderDiffIL4DotNet.Tests.csproj index 089f9dd1..f18e7fa2 100644 --- a/FolderDiffIL4DotNet.Tests/FolderDiffIL4DotNet.Tests.csproj +++ b/FolderDiffIL4DotNet.Tests/FolderDiffIL4DotNet.Tests.csproj @@ -12,6 +12,7 @@ + diff --git a/FolderDiffIL4DotNet.Tests/Models/ConfigSettingsTests.InlineDiffAndMutation.cs b/FolderDiffIL4DotNet.Tests/Models/ConfigSettingsTests.InlineDiffAndMutation.cs index 89710119..b4b8489a 100644 --- a/FolderDiffIL4DotNet.Tests/Models/ConfigSettingsTests.InlineDiffAndMutation.cs +++ b/FolderDiffIL4DotNet.Tests/Models/ConfigSettingsTests.InlineDiffAndMutation.cs @@ -176,8 +176,10 @@ public void Readme_ValidatedConstraints_StayInSyncWithCurrentConfigValidationRul Assert.Contains("`--print-config` は、環境変数と対応 CLI オーバーライドを適用した builder 状態を、セマンティック検証なしでそのまま出力するため、範囲外を含む effective config の診断にも使えます。", userGuide, StringComparison.Ordinal); Assert.Contains("[`InlineDiffContextLines`](#config-en-inlinediffcontextlines) >= `0`", userGuide, StringComparison.Ordinal); Assert.Contains("[`ILCacheMaxMemoryMegabytes`](#config-en-ilcachemaxmemorymegabytes) >= `0`", userGuide, StringComparison.Ordinal); + Assert.Contains("[`ILNormalizeContainingStrings`](#config-en-ilnormalizecontainingstrings) must contain at most `256` combined configured/profile entries of at most `4096` characters each", userGuide, StringComparison.Ordinal); Assert.Contains("[`InlineDiffContextLines`](#config-ja-inlinediffcontextlines) >= `0`", userGuide, StringComparison.Ordinal); Assert.Contains("[`ILCacheMaxMemoryMegabytes`](#config-ja-ilcachemaxmemorymegabytes) >= `0`", userGuide, StringComparison.Ordinal); + Assert.Contains("[`ILNormalizeContainingStrings`](#config-ja-ilnormalizecontainingstrings) は設定値とプロファイル値の結合後が最大 `256` 件かつ各値が最大 `4096` 文字", userGuide, StringComparison.Ordinal); } [Fact] diff --git a/FolderDiffIL4DotNet.Tests/Models/ConfigSettingsTests.Schema.cs b/FolderDiffIL4DotNet.Tests/Models/ConfigSettingsTests.Schema.cs index 622a8dcf..e121ec82 100644 --- a/FolderDiffIL4DotNet.Tests/Models/ConfigSettingsTests.Schema.cs +++ b/FolderDiffIL4DotNet.Tests/Models/ConfigSettingsTests.Schema.cs @@ -131,5 +131,23 @@ public void Schema_IsValidJsonWithRequiredTopLevelKeys() Assert.Equal("object", typeVal.GetString()); Assert.True(root.TryGetProperty("properties", out _), "Missing properties"); } + + [Fact] + [Trait("Category", "Unit")] + public void Schema_ILNormalizationLimits_MatchRuntimeConstants() + { + var schemaPath = Path.Combine(FindRepoRoot(), "doc", "config.schema.json"); + using var schemaDoc = JsonDocument.Parse(File.ReadAllText(schemaPath)); + JsonElement property = schemaDoc.RootElement + .GetProperty("properties") + .GetProperty(nameof(ConfigSettings.ILNormalizeContainingStrings)); + + Assert.Equal( + ConfigSettings.MaxILNormalizeContainingStringsCount, + property.GetProperty("maxItems").GetInt32()); + Assert.Equal( + ConfigSettings.MaxILNormalizeContainingStringLength, + property.GetProperty("items").GetProperty("maxLength").GetInt32()); + } } } diff --git a/FolderDiffIL4DotNet.Tests/Models/ConfigSettingsTests.ValidationBoundary.cs b/FolderDiffIL4DotNet.Tests/Models/ConfigSettingsTests.ValidationBoundary.cs index 2f0bccbd..5eec205f 100644 --- a/FolderDiffIL4DotNet.Tests/Models/ConfigSettingsTests.ValidationBoundary.cs +++ b/FolderDiffIL4DotNet.Tests/Models/ConfigSettingsTests.ValidationBoundary.cs @@ -3,6 +3,8 @@ /// 比較演算子に対する Stryker ミューテーションをキルするバリデーション境界テストを含むパーシャルクラス。 /// +using System.Collections.Generic; +using System.Linq; using FolderDiffIL4DotNet.Models; using Xunit; @@ -110,6 +112,8 @@ public void DefaultConstants_NumericValues_MatchExpectedLiterals() Assert.Equal(2048, ConfigSettings.DefaultILPrecomputeBatchSize); Assert.Equal(10, ConfigSettings.DefaultDisassemblerBlacklistTtlMinutes); Assert.Equal(60, ConfigSettings.DefaultDisassemblerTimeoutSeconds); + Assert.Equal(256, ConfigSettings.MaxILNormalizeContainingStringsCount); + Assert.Equal(4096, ConfigSettings.MaxILNormalizeContainingStringLength); Assert.Equal(4, ConfigSettings.DefaultInlineDiffContextLines); Assert.Equal(4000, ConfigSettings.DefaultInlineDiffMaxEditDistance); Assert.Equal(10000, ConfigSettings.DefaultInlineDiffMaxDiffLines); @@ -158,5 +162,89 @@ public void Validate_ChunkSizeOneLessThanThreshold_IsValid() var result = builder.Validate(); Assert.True(result.IsValid); } + + [Fact] + [Trait("Category", "Unit")] + public void Validate_ILNormalizationLimits_ExactBoundariesAreValid() + { + var values = Enumerable.Range(0, ConfigSettings.MaxILNormalizeContainingStringsCount) + .Select(index => index == 0 + ? new string('x', ConfigSettings.MaxILNormalizeContainingStringLength) + : $"normalization-{index}") + .ToList(); + var builder = new ConfigSettingsBuilder + { + ILNormalizeContainingStrings = values + }; + + var result = builder.Validate(); + + Assert.True(result.IsValid); + } + + [Fact] + [Trait("Category", "Unit")] + public void Validate_ILNormalizationCountAboveLimit_ReturnsError() + { + var builder = new ConfigSettingsBuilder + { + ILNormalizeContainingStrings = Enumerable.Range( + 0, + ConfigSettings.MaxILNormalizeContainingStringsCount + 1) + .Select(index => $"normalization-{index}") + .ToList() + }; + + var result = builder.Validate(); + + string error = Assert.Single(result.Errors); + Assert.Contains("at most 256 values", error, System.StringComparison.Ordinal); + Assert.Contains("current count: 257", error, System.StringComparison.Ordinal); + } + + [Fact] + [Trait("Category", "Unit")] + public void Validate_ILNormalizationValueAboveLengthLimit_ReturnsIndexedError() + { + var builder = new ConfigSettingsBuilder + { + ILNormalizeContainingStrings = new List + { + "normalization", + new string('x', ConfigSettings.MaxILNormalizeContainingStringLength + 1) + } + }; + + var result = builder.Validate(); + + string error = Assert.Single(result.Errors); + Assert.Contains("ILNormalizeContainingStrings[1]", error, System.StringComparison.Ordinal); + Assert.Contains("4096 Unicode characters or fewer", error, System.StringComparison.Ordinal); + } + + [Fact] + [Trait("Category", "Unit")] + public void Validate_ILNormalizationLength_UsesUnicodeCodePointsLikeJsonSchema() + { + string nonBmpCharacter = char.ConvertFromUtf32(0x1F600); + string maximumLengthValue = string.Concat(Enumerable.Repeat( + nonBmpCharacter, + ConfigSettings.MaxILNormalizeContainingStringLength)); + string overLimitValue = maximumLengthValue + nonBmpCharacter; + + var validResult = new ConfigSettingsBuilder + { + ILNormalizeContainingStrings = new List { maximumLengthValue } + }.Validate(); + var invalidResult = new ConfigSettingsBuilder + { + ILNormalizeContainingStrings = new List { overLimitValue } + }.Validate(); + + Assert.Equal(ConfigSettings.MaxILNormalizeContainingStringLength * 2, maximumLengthValue.Length); + Assert.True(validResult.IsValid); + string error = Assert.Single(invalidResult.Errors); + Assert.Contains("4096 Unicode characters or fewer", error, System.StringComparison.Ordinal); + } } } diff --git a/FolderDiffIL4DotNet.Tests/Models/ConfigSettingsTests.cs b/FolderDiffIL4DotNet.Tests/Models/ConfigSettingsTests.cs index db6510d7..a3168081 100644 --- a/FolderDiffIL4DotNet.Tests/Models/ConfigSettingsTests.cs +++ b/FolderDiffIL4DotNet.Tests/Models/ConfigSettingsTests.cs @@ -54,6 +54,8 @@ public void JsonDeserialize_ExplicitOverrides_AreApplied() "ShouldOutputILText": false, "ShouldIgnoreILLinesContainingConfiguredStrings": true, "ILIgnoreLineContainingStrings": ["buildserver", "path"], + "ShouldILNormalizeContainingConfiguredStrings": true, + "ILNormalizeContainingStrings": ["buildserver1_", "buildserver2_"], "ShouldOutputFileTimestamps": false, "ShouldWarnWhenNewFileTimestampIsOlderThanOldFileTimestamp": false, "MaxParallelism": 8, @@ -89,6 +91,8 @@ public void JsonDeserialize_ExplicitOverrides_AreApplied() Assert.False(config.ShouldOutputILText); Assert.True(config.ShouldIgnoreILLinesContainingConfiguredStrings); Assert.Equal(new[] { "buildserver", "path" }, config.ILIgnoreLineContainingStrings); + Assert.True(config.ShouldILNormalizeContainingConfiguredStrings); + Assert.Equal(new[] { "buildserver1_", "buildserver2_" }, config.ILNormalizeContainingStrings); Assert.False(config.ShouldOutputFileTimestamps); Assert.False(config.ShouldWarnWhenNewFileTimestampIsOlderThanOldFileTimestamp); Assert.Equal(8, config.MaxParallelism); @@ -142,6 +146,7 @@ public void JsonDeserialize_NullCollectionsAndCachePath_FallBackToDefaults() "IgnoredExtensions": null, "TextFileExtensions": null, "ILIgnoreLineContainingStrings": null, + "ILNormalizeContainingStrings": null, "ILCacheDirectoryAbsolutePath": null } """; @@ -153,6 +158,8 @@ public void JsonDeserialize_NullCollectionsAndCachePath_FallBackToDefaults() Assert.Equal(ExpectedDefaultTextFileExtensions, config.TextFileExtensions); Assert.NotNull(config.ILIgnoreLineContainingStrings); Assert.Empty(config.ILIgnoreLineContainingStrings); + Assert.NotNull(config.ILNormalizeContainingStrings); + Assert.Empty(config.ILNormalizeContainingStrings); Assert.Equal(string.Empty, config.ILCacheDirectoryAbsolutePath); } @@ -167,6 +174,21 @@ public void Validate_DefaultSettings_IsValid() Assert.Empty(result.Errors); } + [Theory] + [InlineData("ShouldIgnoreMVID")] + [InlineData("shouldignoremvid")] + public void Validate_RemovedShouldIgnoreMvidSetting_ReturnsMigrationError(string propertyName) + { + var builder = JsonSerializer.Deserialize($"{{ \"{propertyName}\": false }}")!; + + var result = builder.Validate(); + + Assert.False(result.IsValid); + string error = Assert.Single(result.Errors); + Assert.Contains("'ShouldIgnoreMVID' setting has been removed", error, StringComparison.Ordinal); + Assert.Contains("always normalized", error, StringComparison.Ordinal); + } + [Theory] [InlineData(0)] [InlineData(-1)] @@ -357,6 +379,9 @@ private static void AssertMatchesDefaults(ConfigSettings config) Assert.Equal(ConfigSettings.DefaultShouldIgnoreILLinesContainingConfiguredStrings, config.ShouldIgnoreILLinesContainingConfiguredStrings); Assert.NotNull(config.ILIgnoreLineContainingStrings); Assert.Empty(config.ILIgnoreLineContainingStrings); + Assert.Equal(ConfigSettings.DefaultShouldILNormalizeContainingConfiguredStrings, config.ShouldILNormalizeContainingConfiguredStrings); + Assert.NotNull(config.ILNormalizeContainingStrings); + Assert.Empty(config.ILNormalizeContainingStrings); Assert.Equal(ConfigSettings.DefaultShouldOutputFileTimestamps, config.ShouldOutputFileTimestamps); Assert.Equal(ConfigSettings.DefaultShouldWarnWhenNewFileTimestampIsOlderThanOldFileTimestamp, config.ShouldWarnWhenNewFileTimestampIsOlderThanOldFileTimestamp); Assert.Equal(ConfigSettings.DefaultMaxParallelism, config.MaxParallelism); diff --git a/FolderDiffIL4DotNet.Tests/ProgramRunnerTests.CliOverrides.cs b/FolderDiffIL4DotNet.Tests/ProgramRunnerTests.CliOverrides.cs index 3d0546bf..f4274a66 100644 --- a/FolderDiffIL4DotNet.Tests/ProgramRunnerTests.CliOverrides.cs +++ b/FolderDiffIL4DotNet.Tests/ProgramRunnerTests.CliOverrides.cs @@ -3,6 +3,7 @@ using System; using System.IO; +using System.Net.Http; using System.Threading.Tasks; using FolderDiffIL4DotNet.Runner; using FolderDiffIL4DotNet.Services; @@ -533,8 +534,10 @@ public async Task RunAsync_ValidateConfigFlag_WithInvalidBundledFallback_ShowsRe } } - [Fact] - public async Task RunAsync_ValidateConfigFlag_WithUnknownCreatorProfile_ReturnsInvalidArguments() + [Theory] + [InlineData("nope")] + [InlineData("buildserver-winforms")] + public async Task RunAsync_ValidateConfigFlag_WithUnknownCreatorProfile_ReturnsInvalidArguments(string profileName) { var logger = new TestLogger(logFileAbsolutePath: "test.log"); var runner = new ProgramRunner(logger, new ConfigService()); @@ -546,10 +549,11 @@ public async Task RunAsync_ValidateConfigFlag_WithUnknownCreatorProfile_ReturnsI { await WithConfigFileAsync("{}", async () => { - var exitCode = await runner.RunAsync(new[] { "--validate-config", "--creator-il-ignore-profile", "nope" }); + var exitCode = await runner.RunAsync(new[] { "--validate-config", "--creator-il-ignore-profile", profileName }); Assert.Equal(2, exitCode); - Assert.Contains("'--creator-il-ignore-profile' requires a known profile name. Got: 'nope'.", errorWriter.ToString(), StringComparison.Ordinal); + Assert.Contains($"'--creator-il-ignore-profile' requires a known profile name. Got: '{profileName}'.", errorWriter.ToString(), StringComparison.Ordinal); + Assert.Contains("Known profiles: creator-default", errorWriter.ToString(), StringComparison.Ordinal); Assert.Empty(logger.Messages); }); } @@ -559,6 +563,55 @@ await WithConfigFileAsync("{}", async () => } } + [Fact] + public async Task RunAsync_ClearCacheFlag_WithRemovedCreatorProfile_ReturnsInvalidArguments() + { + var logger = new TestLogger(logFileAbsolutePath: "test.log"); + bool updateCheckCalled = false; + using var httpClient = new HttpClient(); + var updateNotificationService = new UpdateNotificationService( + httpClient, + static () => throw new InvalidOperationException("Update cache must not be resolved for invalid arguments."), + static () => DateTimeOffset.UtcNow, + () => + { + updateCheckCalled = true; + return false; + }); + var runner = new ProgramRunner( + logger, + new ConfigService(), + static _ => { }, + updateNotificationService); + var originalError = Console.Error; + using var errorWriter = new StringWriter(); + Console.SetError(errorWriter); + + try + { + int exitCode = await runner.RunAsync(new[] + { + "--clear-cache", + "--creator-il-ignore-profile", + "buildserver-winforms" + }); + + Assert.Equal(2, exitCode); + Assert.Contains( + "'--creator-il-ignore-profile' requires a known profile name. Got: 'buildserver-winforms'.", + errorWriter.ToString(), + StringComparison.Ordinal); + Assert.Contains("Known profiles: creator-default", errorWriter.ToString(), StringComparison.Ordinal); + Assert.DoesNotContain("--clear-cache requires an interactive terminal", errorWriter.ToString(), StringComparison.Ordinal); + Assert.False(updateCheckCalled); + Assert.Empty(logger.Messages); + } + finally + { + Console.SetError(originalError); + } + } + [Fact] public void DeleteCacheFileForClearCache_ReadOnlyFile_DeletesFile() { @@ -633,8 +686,10 @@ await WithConfigFileAsync("{}", async () => } } - [Fact] - public async Task RunAsync_PrintConfigFlag_WithUnknownCreatorProfile_ReturnsInvalidArguments() + [Theory] + [InlineData("nope")] + [InlineData("buildserver-winforms")] + public async Task RunAsync_PrintConfigFlag_WithUnknownCreatorProfile_ReturnsInvalidArguments(string profileName) { var logger = new TestLogger(logFileAbsolutePath: "test.log"); var runner = new ProgramRunner(logger, new ConfigService()); @@ -646,10 +701,11 @@ public async Task RunAsync_PrintConfigFlag_WithUnknownCreatorProfile_ReturnsInva { await WithConfigFileAsync("{}", async () => { - var exitCode = await runner.RunAsync(new[] { "--print-config", "--creator-il-ignore-profile", "nope" }); + var exitCode = await runner.RunAsync(new[] { "--print-config", "--creator-il-ignore-profile", profileName }); Assert.Equal(2, exitCode); - Assert.Contains("'--creator-il-ignore-profile' requires a known profile name. Got: 'nope'.", errorWriter.ToString(), StringComparison.Ordinal); + Assert.Contains($"'--creator-il-ignore-profile' requires a known profile name. Got: '{profileName}'.", errorWriter.ToString(), StringComparison.Ordinal); + Assert.Contains("Known profiles: creator-default", errorWriter.ToString(), StringComparison.Ordinal); Assert.Empty(logger.Messages); }); } @@ -660,7 +716,7 @@ await WithConfigFileAsync("{}", async () => } [Fact] - public async Task RunAsync_PrintConfigFlag_WithCreatorIlIgnoreProfile_OutputsMergedIlFilters() + public async Task RunAsync_PrintConfigFlag_WithCreatorIlIgnoreProfile_OutputsMergedIlNormalizations() { var logger = new TestLogger(logFileAbsolutePath: "test.log"); var runner = new ProgramRunner(logger, new ConfigService()); @@ -671,7 +727,8 @@ public async Task RunAsync_PrintConfigFlag_WithCreatorIlIgnoreProfile_OutputsMer const string configJson = """ { "ShouldIgnoreILLinesContainingConfiguredStrings": false, - "ILIgnoreLineContainingStrings": ["existing-filter"] + "ILIgnoreLineContainingStrings": ["existing-filter"], + "ILNormalizeContainingStrings": ["existing-normalization"] } """; @@ -679,14 +736,19 @@ public async Task RunAsync_PrintConfigFlag_WithCreatorIlIgnoreProfile_OutputsMer { await WithConfigFileAsync(configJson, async () => { - var exitCode = await runner.RunAsync(new[] { "--creator-il-ignore-profile", "buildserver-winforms", "--print-config" }); + var exitCode = await runner.RunAsync(new[] { "--creator-il-ignore-profile", "creator-default", "--print-config" }); Assert.Equal(0, exitCode); var output = sw.ToString(); - Assert.Contains("\"ShouldIgnoreILLinesContainingConfiguredStrings\": true", output, StringComparison.Ordinal); + Assert.Contains("\"ShouldIgnoreILLinesContainingConfiguredStrings\": false", output, StringComparison.Ordinal); + Assert.Contains("\"ShouldILNormalizeContainingConfiguredStrings\": true", output, StringComparison.Ordinal); Assert.Contains("existing-filter", output, StringComparison.Ordinal); + Assert.Contains("existing-normalization", output, StringComparison.Ordinal); Assert.Contains("buildserver1_", output, StringComparison.Ordinal); - Assert.Contains("// Code size ", output, StringComparison.Ordinal); + Assert.True( + output.IndexOf("buildserver1_", StringComparison.Ordinal) + < output.IndexOf("existing-normalization", StringComparison.Ordinal)); + Assert.DoesNotContain("// Code size ", output, StringComparison.Ordinal); Assert.Contains(@"\u00A5\u00A5temp\u00A5\u00A5develop\u00A5\u00A5", output, StringComparison.Ordinal); Assert.Contains(@"\\\\temp\\\\develop\\\\", output, StringComparison.Ordinal); }); @@ -714,7 +776,8 @@ await WithConfigFileAsync("{}", async () => Assert.Equal(0, exitCode); var output = sw.ToString(); - Assert.Contains("\"ShouldIgnoreILLinesContainingConfiguredStrings\": true", output, StringComparison.Ordinal); + Assert.Contains("\"ShouldIgnoreILLinesContainingConfiguredStrings\": false", output, StringComparison.Ordinal); + Assert.Contains("\"ShouldILNormalizeContainingConfiguredStrings\": true", output, StringComparison.Ordinal); Assert.Contains("buildserver1_", output, StringComparison.Ordinal); Assert.Contains(@"\u00A5\u00A5temp\u00A5\u00A5develop\u00A5\u00A5", output, StringComparison.Ordinal); Assert.Contains(@"\\\\temp\\\\develop\\\\", output, StringComparison.Ordinal); diff --git a/FolderDiffIL4DotNet.Tests/ProgramRunnerTests.HelpVersion.cs b/FolderDiffIL4DotNet.Tests/ProgramRunnerTests.HelpVersion.cs index 3979daca..2e618e9c 100644 --- a/FolderDiffIL4DotNet.Tests/ProgramRunnerTests.HelpVersion.cs +++ b/FolderDiffIL4DotNet.Tests/ProgramRunnerTests.HelpVersion.cs @@ -1486,15 +1486,23 @@ public async Task RunAsync_FourthPositionalArgument_ReturnsInvalidArgumentsAndPr { var logger = new TestLogger(); var runner = new ProgramRunner(logger, new ConfigService()); + var originalError = Console.Error; + using var errorWriter = new StringWriter(); + Console.SetError(errorWriter); - var exitCode = await runner.RunAsync(new[] { "old", "new", "label", "surplus", "--no-pause" }); + try + { + var exitCode = await runner.RunAsync(new[] { "old", "new", "label", "surplus", "--no-pause" }); - Assert.Equal(2, exitCode); - Assert.Contains( - logger.Entries, - entry => entry.ShouldOutputMessageToConsole - && entry.Message.Contains("surplus", StringComparison.Ordinal) - && entry.Message.Contains("Usage:", StringComparison.Ordinal)); + Assert.Equal(2, exitCode); + Assert.Contains("surplus", errorWriter.ToString(), StringComparison.Ordinal); + Assert.Contains("Usage:", errorWriter.ToString(), StringComparison.Ordinal); + Assert.Empty(logger.Entries); + } + finally + { + Console.SetError(originalError); + } } } } diff --git a/FolderDiffIL4DotNet.Tests/ProgramRunnerTests.RemovedSettings.cs b/FolderDiffIL4DotNet.Tests/ProgramRunnerTests.RemovedSettings.cs new file mode 100644 index 00000000..cf3cc8ec --- /dev/null +++ b/FolderDiffIL4DotNet.Tests/ProgramRunnerTests.RemovedSettings.cs @@ -0,0 +1,106 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using FolderDiffIL4DotNet.Runner; +using FolderDiffIL4DotNet.Services; +using FolderDiffIL4DotNet.Tests.Helpers; +using Xunit; + +namespace FolderDiffIL4DotNet.Tests +{ + public sealed partial class ProgramRunnerTests + { + [Theory] + [InlineData("--validate-config")] + [InlineData("--print-config")] + public async Task RunAsync_RemovedShouldIgnoreMvidSetting_ReturnsMigrationError(string command) + { + var logger = new TestLogger(logFileAbsolutePath: "test.log"); + var runner = new ProgramRunner(logger, new ConfigService()); + var originalOut = Console.Out; + var originalError = Console.Error; + using var outputWriter = new StringWriter(); + using var errorWriter = new StringWriter(); + Console.SetOut(outputWriter); + Console.SetError(errorWriter); + + try + { + await WithConfigFileAsync("""{ "ShouldIgnoreMVID": false }""", async () => + { + int exitCode = await runner.RunAsync(new[] { command }); + + Assert.Equal(3, exitCode); + Assert.Contains("'ShouldIgnoreMVID' setting has been removed", errorWriter.ToString(), StringComparison.Ordinal); + Assert.Contains("always normalized", errorWriter.ToString(), StringComparison.Ordinal); + Assert.DoesNotContain("\"ShouldIgnoreMVID\"", outputWriter.ToString(), StringComparison.Ordinal); + }); + } + finally + { + Console.SetOut(originalOut); + Console.SetError(originalError); + } + } + + [Theory] + [InlineData("ShouldIgnoreMVID")] + [InlineData("shouldignoremvid")] + public async Task RunAsync_ClearCacheFlag_WithRemovedShouldIgnoreMvidSetting_ReturnsMigrationError(string propertyName) + { + var logger = new TestLogger(logFileAbsolutePath: "test.log"); + var runner = new ProgramRunner(logger, new ConfigService()); + var originalError = Console.Error; + using var errorWriter = new StringWriter(); + Console.SetError(errorWriter); + + try + { + await WithConfigFileAsync($"{{ \"{propertyName}\": false }}", async () => + { + int exitCode = await runner.RunAsync(new[] { "--clear-cache" }); + + Assert.Equal(3, exitCode); + Assert.Contains("'ShouldIgnoreMVID' setting has been removed", errorWriter.ToString(), StringComparison.Ordinal); + Assert.Contains("always normalized", errorWriter.ToString(), StringComparison.Ordinal); + Assert.DoesNotContain("--clear-cache requires an interactive terminal", errorWriter.ToString(), StringComparison.Ordinal); + }); + } + finally + { + Console.SetError(originalError); + } + } + + [Fact] + public async Task RunAsync_ClearCacheFlag_WithRemovedShouldIgnoreMvidEnvironmentVariable_ReturnsMigrationError() + { + const string removedEnvironmentVariable = "FOLDERDIFF_SHOULDIGNOREMVID"; + string? originalValue = Environment.GetEnvironmentVariable(removedEnvironmentVariable); + var logger = new TestLogger(logFileAbsolutePath: "test.log"); + var runner = new ProgramRunner(logger, new ConfigService()); + var originalError = Console.Error; + using var errorWriter = new StringWriter(); + Console.SetError(errorWriter); + + try + { + Environment.SetEnvironmentVariable(removedEnvironmentVariable, "false"); + await WithConfigFileAsync("{}", async () => + { + int exitCode = await runner.RunAsync(new[] { "--clear-cache" }); + + Assert.Equal(3, exitCode); + Assert.Contains($"'{removedEnvironmentVariable}' has been removed", errorWriter.ToString(), StringComparison.Ordinal); + Assert.Contains("always normalized", errorWriter.ToString(), StringComparison.Ordinal); + Assert.DoesNotContain("--clear-cache requires an interactive terminal", errorWriter.ToString(), StringComparison.Ordinal); + }); + } + finally + { + Environment.SetEnvironmentVariable(removedEnvironmentVariable, originalValue); + Console.SetError(originalError); + } + } + } +} diff --git a/FolderDiffIL4DotNet.Tests/ProgramRunnerTests.cs b/FolderDiffIL4DotNet.Tests/ProgramRunnerTests.cs index bc9d1d2d..ff92a602 100644 --- a/FolderDiffIL4DotNet.Tests/ProgramRunnerTests.cs +++ b/FolderDiffIL4DotNet.Tests/ProgramRunnerTests.cs @@ -250,6 +250,25 @@ public void CreateIlCache_WhenPathIsEmpty_DefaultsToLocalApplicationDataSubfolde Assert.DoesNotContain(AppContext.BaseDirectory, cacheDir); } + [Fact] + public void OutputCompletionWarnings_WhenConfiguredIlSubstringWarningExists_UsesGenericSafetyMessage() + { + var logger = new TestLogger(logFileAbsolutePath: "test.log"); + var runner = new ProgramRunner(logger, new ConfigService()); + var method = typeof(ProgramRunner).GetMethod( + "OutputCompletionWarnings", + BindingFlags.Instance | BindingFlags.NonPublic); + + Assert.NotNull(method); + method.Invoke(runner, new object[] { false, false, true }); + + var warning = Assert.Single(logger.Entries); + Assert.Equal(AppLogLevel.Warning, warning.LogLevel); + Assert.Contains("configured IL substring safety warnings", warning.Message, StringComparison.Ordinal); + Assert.DoesNotContain("ILIgnoreLineContainingStrings", warning.Message, StringComparison.Ordinal); + Assert.DoesNotContain("very short", warning.Message, StringComparison.Ordinal); + } + private static AppDataOverrideScope CreateAppDataOverrideScope() => new(Path.Combine(Path.GetTempPath(), "fd-runner-appdata-" + Guid.NewGuid().ToString("N"))); } diff --git a/FolderDiffIL4DotNet.Tests/Runner/CliOverrideApplierTests.cs b/FolderDiffIL4DotNet.Tests/Runner/CliOverrideApplierTests.cs index feba0ed5..460dd3f5 100644 --- a/FolderDiffIL4DotNet.Tests/Runner/CliOverrideApplierTests.cs +++ b/FolderDiffIL4DotNet.Tests/Runner/CliOverrideApplierTests.cs @@ -1,5 +1,9 @@ +using System; +using System.Collections.Generic; +using System.Linq; using FolderDiffIL4DotNet.Models; using FolderDiffIL4DotNet.Runner; +using FolderDiffIL4DotNet.Services; using Xunit; namespace FolderDiffIL4DotNet.Tests.Runner @@ -99,26 +103,71 @@ public void Apply_DefaultOptions_DoesNotModifyBuilder() } [Fact] - public void Apply_CreatorIlIgnoreProfile_EnablesFilteringAndMergesStrings() + public void Apply_CreatorIlIgnoreProfile_PrependsProfileBeforeConfiguredNormalizationStrings() { var builder = new ConfigSettingsBuilder { ShouldIgnoreILLinesContainingConfiguredStrings = false, - ILIgnoreLineContainingStrings = new System.Collections.Generic.List { "existing-filter" } + ILIgnoreLineContainingStrings = new List { "existing-filter" }, + ILNormalizeContainingStrings = new List { "existing-normalization" } }; - var opts = DefaultOpts() with { CreatorIlIgnoreProfile = "buildserver-winforms" }; + var opts = DefaultOpts() with { CreatorIlIgnoreProfile = "creator-default" }; CliOverrideApplier.Apply(builder, opts); - Assert.True(builder.ShouldIgnoreILLinesContainingConfiguredStrings); + Assert.False(builder.ShouldIgnoreILLinesContainingConfiguredStrings); + Assert.True(builder.ShouldILNormalizeContainingConfiguredStrings); Assert.Contains("existing-filter", builder.ILIgnoreLineContainingStrings); - Assert.Contains("buildserver1_", builder.ILIgnoreLineContainingStrings); - Assert.Contains("// Code size ", builder.ILIgnoreLineContainingStrings); - Assert.Contains("¥temp¥develop¥", builder.ILIgnoreLineContainingStrings); - Assert.Contains("¥¥temp¥¥develop¥¥", builder.ILIgnoreLineContainingStrings); - Assert.Contains(@"\temp\develop\", builder.ILIgnoreLineContainingStrings); - Assert.Contains(@"\\temp\\develop\\", builder.ILIgnoreLineContainingStrings); - Assert.Contains("/temp/develop/", builder.ILIgnoreLineContainingStrings); + Assert.Equal( + new[] + { + "buildserver1_", + "buildserver2_", + "¥temp¥develop¥", + "¥¥temp¥¥develop¥¥", + @"\temp\develop\", + @"\\temp\\develop\\", + "/temp/develop/", + "existing-normalization" + }, + builder.ILNormalizeContainingStrings); + Assert.DoesNotContain("// Method begins at Relative Virtual Address (RVA) 0x", builder.ILNormalizeContainingStrings); + Assert.DoesNotContain("// Code size ", builder.ILNormalizeContainingStrings); + Assert.DoesNotContain("TypeLibraryTimeStampAttribute", builder.ILNormalizeContainingStrings); + Assert.DoesNotContain(".publickeytoken = ( ", builder.ILIgnoreLineContainingStrings); + } + + [Fact] + public void Apply_CreatorFlag_DoesNotSuppressPublicKeyTokenDifferences() + { + var builder = new ConfigSettingsBuilder(); + var opts = DefaultOpts() with { Creator = true }; + + CliOverrideApplier.Apply(builder, opts); + + var oldLines = new[] + { + ".assembly extern Vendor.Library", + "{", + " .publickeytoken = ( 12 34 56 78 90 AB CD EF )", + "}" + }; + var newLines = new[] + { + ".assembly extern Vendor.Library", + "{", + " .publickeytoken = ( FE DC BA 09 87 65 43 21 )", + "}" + }; + + var areEqual = ILOutputService.StreamingFilteredSequenceEqual( + oldLines, + newLines, + builder.ShouldIgnoreILLinesContainingConfiguredStrings, + builder.ILIgnoreLineContainingStrings, + builder.ILNormalizeContainingStrings); + + Assert.False(areEqual); } [Fact] @@ -129,11 +178,51 @@ public void Apply_CreatorFlag_UsesDefaultProfile() CliOverrideApplier.Apply(builder, opts); - Assert.True(builder.ShouldIgnoreILLinesContainingConfiguredStrings); - Assert.Contains("buildserver1_", builder.ILIgnoreLineContainingStrings); - Assert.Contains("¥¥temp¥¥develop¥¥", builder.ILIgnoreLineContainingStrings); - Assert.Contains(@"\\temp\\develop\\", builder.ILIgnoreLineContainingStrings); - Assert.Contains("/temp/develop/", builder.ILIgnoreLineContainingStrings); + Assert.True(builder.ShouldILNormalizeContainingConfiguredStrings); + Assert.Contains("buildserver1_", builder.ILNormalizeContainingStrings); + Assert.Contains("¥¥temp¥¥develop¥¥", builder.ILNormalizeContainingStrings); + Assert.Contains(@"\\temp\\develop\\", builder.ILNormalizeContainingStrings); + Assert.Contains("/temp/develop/", builder.ILNormalizeContainingStrings); + } + + [Fact] + public void Apply_CreatorFlag_PreservesDuplicateForValidationBeforeEffectiveDeduplication() + { + var builder = new ConfigSettingsBuilder + { + ILNormalizeContainingStrings = new List { "buildserver1_" } + }; + + CliOverrideApplier.Apply(builder, DefaultOpts() with { Creator = true }); + + Assert.Equal(2, builder.ILNormalizeContainingStrings.Count(value => value == "buildserver1_")); + Assert.Contains( + ILOutputService.ValidateILNormalizeContainingStrings(builder.ILNormalizeContainingStrings), + warning => warning.Contains("configured more than once", StringComparison.Ordinal)); + } + + [Fact] + public void Apply_CreatorFlag_ProfileValuesCountTowardRuntimeNormalizationLimit() + { + var builder = new ConfigSettingsBuilder + { + ILNormalizeContainingStrings = Enumerable.Range( + 0, + ConfigSettings.MaxILNormalizeContainingStringsCount) + .Select(index => $"normalization-{index}") + .ToList() + }; + + CliOverrideApplier.Apply(builder, DefaultOpts() with { Creator = true }); + + ConfigValidationResult validation = builder.Validate(); + Assert.False(validation.IsValid); + Assert.Contains( + validation.Errors, + error => error.Contains("Creator-profile values", StringComparison.Ordinal) + && error.Contains( + $"at most {ConfigSettings.MaxILNormalizeContainingStringsCount} values", + StringComparison.Ordinal)); } [Fact] diff --git a/FolderDiffIL4DotNet.Tests/Services/ConfigServiceTests.EnvVarsAndMutation.cs b/FolderDiffIL4DotNet.Tests/Services/ConfigServiceTests.EnvVarsAndMutation.cs index 95cac660..49844385 100644 --- a/FolderDiffIL4DotNet.Tests/Services/ConfigServiceTests.EnvVarsAndMutation.cs +++ b/FolderDiffIL4DotNet.Tests/Services/ConfigServiceTests.EnvVarsAndMutation.cs @@ -84,6 +84,42 @@ await WithEnvVarsAsync( }); } + [Fact] + public async Task LoadConfigBuilderAsync_EnvVarOverridesShouldIlNormalize_AppliesOverride() + { + await WithConfigFileAsync("{}", async () => + { + await WithEnvVarsAsync( + new[] { ("FOLDERDIFF_SHOULDILNORMALIZECONTAININGCONFIGUREDSTRINGS", "true") }, + async () => + { + var service = new ConfigService(); + var builder = await service.LoadConfigBuilderAsync(); + + Assert.True(builder.ShouldILNormalizeContainingConfiguredStrings); + }); + }); + } + + [Fact] + public async Task LoadConfigBuilderAsync_RemovedShouldIgnoreMvidEnvVar_ThrowsMigrationError() + { + await WithConfigFileAsync("{}", async () => + { + await WithEnvVarsAsync( + new[] { ("FOLDERDIFF_SHOULDIGNOREMVID", "false") }, + async () => + { + var service = new ConfigService(); + + var ex = await Assert.ThrowsAsync(() => service.LoadConfigBuilderAsync()); + + Assert.Contains("'FOLDERDIFF_SHOULDIGNOREMVID' has been removed", ex.Message, StringComparison.Ordinal); + Assert.Contains("always normalized", ex.Message, StringComparison.Ordinal); + }); + }); + } + [Fact] public async Task LoadConfigBuilderAsync_EnvVarOverridesStringProperty_AppliesOverride() { diff --git a/FolderDiffIL4DotNet.Tests/Services/ConfigServiceTests.cs b/FolderDiffIL4DotNet.Tests/Services/ConfigServiceTests.cs index 6164ae31..c25ace0a 100644 --- a/FolderDiffIL4DotNet.Tests/Services/ConfigServiceTests.cs +++ b/FolderDiffIL4DotNet.Tests/Services/ConfigServiceTests.cs @@ -22,6 +22,23 @@ public async Task LoadConfigBuilderAsync_ExplicitConfigFileMissing_ThrowsFileNot Assert.Contains(Path.GetFullPath(missingConfigPath), ex.Message, StringComparison.Ordinal); } + [Theory] + [InlineData("ShouldIgnoreMVID")] + [InlineData("shouldignoremvid")] + public async Task LoadConfigBuilderAsync_RemovedShouldIgnoreMvidSetting_ThrowsMigrationError(string propertyName) + { + await WithConfigFileAsync($"{{ \"{propertyName}\": false }}", async () => + { + var service = new ConfigService(); + + var ex = await Assert.ThrowsAsync(() => service.LoadConfigBuilderAsync()); + + Assert.Contains("'ShouldIgnoreMVID' setting has been removed", ex.Message, StringComparison.Ordinal); + Assert.Contains("always normalized", ex.Message, StringComparison.Ordinal); + Assert.NotNull(ConfigService.TryGetResolvedConfigFileAbsolutePath(ex)); + }); + } + [Fact] public async Task LoadConfigBuilderAsync_DefaultUserConfigMissing_FallsBackToBundledConfig() { diff --git a/FolderDiffIL4DotNet.Tests/Services/DisassemblerHelperTests.cs b/FolderDiffIL4DotNet.Tests/Services/DisassemblerHelperTests.cs index e10120de..138e9790 100644 --- a/FolderDiffIL4DotNet.Tests/Services/DisassemblerHelperTests.cs +++ b/FolderDiffIL4DotNet.Tests/Services/DisassemblerHelperTests.cs @@ -343,7 +343,7 @@ public void BuildInstallSuggestion_ContainsInstallCommands() Assert.False(string.IsNullOrWhiteSpace(suggestion)); Assert.Contains("dotnet tool install -g dotnet-ildasm", suggestion); - Assert.Contains("dotnet tool install -g ilspycmd", suggestion); + Assert.Contains($"dotnet tool install -g ilspycmd --version {Constants.ILSPY_CMD_VERSION}", suggestion); } /// @@ -392,4 +392,3 @@ public void BuildInstallSuggestion_ContainsOsSpecificPathGuidance() } } } - diff --git a/FolderDiffIL4DotNet.Tests/Services/FolderDiffServiceUnitTests.cs b/FolderDiffIL4DotNet.Tests/Services/FolderDiffServiceUnitTests.cs index aa0e9a32..8062e4bf 100644 --- a/FolderDiffIL4DotNet.Tests/Services/FolderDiffServiceUnitTests.cs +++ b/FolderDiffIL4DotNet.Tests/Services/FolderDiffServiceUnitTests.cs @@ -117,6 +117,180 @@ public async Task ExecuteFolderDiffAsync_WhenPrecomputeThrowsRecoverableExceptio && entry.Message.Contains("precompute cache unavailable", StringComparison.Ordinal)); } + [Fact] + public async Task ExecuteFolderDiffAsync_WithShortNormalizationString_RecordsReportWarningAndConsoleLog() + { + const string oldDir = "/virtual/old"; + const string newDir = "/virtual/new"; + const string reportDir = "/virtual/report"; + var config = new ConfigSettingsBuilder + { + IgnoredExtensions = new List(), + TextFileExtensions = new List(), + ShouldOutputILText = false, + ShouldILNormalizeContainingConfiguredStrings = true, + ILNormalizeContainingStrings = new List { "abc" }, + EnableILCache = false, + MaxParallelism = 1, + OptimizeForNetworkShares = false, + AutoDetectNetworkShares = false + }.Build(); + var resultLists = new FileDiffResultLists(); + var logger = new TestLogger(); + var fileSystem = new FakeFileSystemService(); + fileSystem.SetFiles(oldDir, Path.Combine(oldDir, "same.txt")); + fileSystem.SetFiles(newDir, Path.Combine(newDir, "same.txt")); + using var progressReporter = new ProgressReportService(config); + var service = new FolderDiffService( + config, + progressReporter, + CreateExecutionContext(oldDir, newDir, reportDir), + new FakeFileDiffService(new Dictionary { ["same.txt"] = true }), + resultLists, + logger, + fileSystem); + + await service.ExecuteFolderDiffAsync(); + + string warning = Assert.Single(resultLists.ILFilterWarnings); + Assert.Contains("ILNormalizeContainingStrings", warning); + Assert.Contains( + logger.Entries, + entry => entry.LogLevel == AppLogLevel.Warning + && entry.ShouldOutputMessageToConsole + && entry.Message == warning); + } + + [Fact] + public async Task ExecuteFolderDiffAsync_WithNormalizationDisabled_SkipsNormalizationWarnings() + { + const string oldDir = "/virtual/old"; + const string newDir = "/virtual/new"; + var config = new ConfigSettingsBuilder + { + IgnoredExtensions = new List(), + TextFileExtensions = new List(), + ShouldOutputILText = false, + ShouldILNormalizeContainingConfiguredStrings = false, + ILNormalizeContainingStrings = new List { "abc", "abcdef" }, + EnableILCache = false, + MaxParallelism = 1, + OptimizeForNetworkShares = false, + AutoDetectNetworkShares = false + }.Build(); + var resultLists = new FileDiffResultLists(); + var logger = new TestLogger(); + var fileSystem = new FakeFileSystemService(); + fileSystem.SetFiles(oldDir, Path.Combine(oldDir, "same.txt")); + fileSystem.SetFiles(newDir, Path.Combine(newDir, "same.txt")); + using var progressReporter = new ProgressReportService(config); + var service = new FolderDiffService( + config, + progressReporter, + CreateExecutionContext(oldDir, newDir, "/virtual/report"), + new FakeFileDiffService(new Dictionary { ["same.txt"] = true }), + resultLists, + logger, + fileSystem); + + await service.ExecuteFolderDiffAsync(); + + Assert.Empty(resultLists.ILFilterWarnings); + Assert.DoesNotContain( + logger.Entries, + entry => entry.Message.Contains("ILNormalizeContainingStrings", StringComparison.Ordinal)); + } + + [Fact] + public async Task ExecuteFolderDiffAsync_WithNormalizeAndIgnoreWarnings_LogsNormalizationFirst() + { + const string oldDir = "/virtual/old"; + const string newDir = "/virtual/new"; + var config = new ConfigSettingsBuilder + { + IgnoredExtensions = new List(), + TextFileExtensions = new List(), + ShouldOutputILText = false, + ShouldILNormalizeContainingConfiguredStrings = true, + ILNormalizeContainingStrings = new List { "abc" }, + ShouldIgnoreILLinesContainingConfiguredStrings = true, + ILIgnoreLineContainingStrings = new List { "ret" }, + EnableILCache = false, + MaxParallelism = 1, + OptimizeForNetworkShares = false, + AutoDetectNetworkShares = false + }.Build(); + var resultLists = new FileDiffResultLists(); + var logger = new TestLogger(); + var fileSystem = new FakeFileSystemService(); + fileSystem.SetFiles(oldDir, Path.Combine(oldDir, "same.txt")); + fileSystem.SetFiles(newDir, Path.Combine(newDir, "same.txt")); + using var progressReporter = new ProgressReportService(config); + var service = new FolderDiffService( + config, + progressReporter, + CreateExecutionContext(oldDir, newDir, "/virtual/report"), + new FakeFileDiffService(new Dictionary { ["same.txt"] = true }), + resultLists, + logger, + fileSystem); + + await service.ExecuteFolderDiffAsync(); + + var warnings = logger.Entries + .Where(entry => entry.LogLevel == AppLogLevel.Warning && entry.ShouldOutputMessageToConsole) + .Select(entry => entry.Message) + .ToArray(); + Assert.Collection( + warnings, + warning => Assert.Contains("ILNormalizeContainingStrings", warning), + warning => Assert.Contains("ILIgnoreLineContainingStrings", warning)); + } + + [Fact] + public async Task ExecuteFolderDiffAsync_WithOverlappingNormalizationStrings_RecordsReportWarningAndConsoleLog() + { + const string oldDir = "/virtual/old"; + const string newDir = "/virtual/new"; + const string reportDir = "/virtual/report"; + var config = new ConfigSettingsBuilder + { + IgnoredExtensions = new List(), + TextFileExtensions = new List(), + ShouldOutputILText = false, + ShouldILNormalizeContainingConfiguredStrings = true, + ILNormalizeContainingStrings = new List { "buildserver1_", "buildserver1_artifact" }, + EnableILCache = false, + MaxParallelism = 1, + OptimizeForNetworkShares = false, + AutoDetectNetworkShares = false + }.Build(); + var resultLists = new FileDiffResultLists(); + var logger = new TestLogger(); + var fileSystem = new FakeFileSystemService(); + fileSystem.SetFiles(oldDir, Path.Combine(oldDir, "same.txt")); + fileSystem.SetFiles(newDir, Path.Combine(newDir, "same.txt")); + using var progressReporter = new ProgressReportService(config); + var service = new FolderDiffService( + config, + progressReporter, + CreateExecutionContext(oldDir, newDir, reportDir), + new FakeFileDiffService(new Dictionary { ["same.txt"] = true }), + resultLists, + logger, + fileSystem); + + await service.ExecuteFolderDiffAsync(); + + string warning = Assert.Single(resultLists.ILFilterWarnings); + Assert.Contains("overlap by containment", warning); + Assert.Contains( + logger.Entries, + entry => entry.LogLevel == AppLogLevel.Warning + && entry.ShouldOutputMessageToConsole + && entry.Message == warning); + } + // Verify that an IOException (e.g. symlink loop ELOOP) during file enumeration is logged and rethrown // ファイル列挙時の IOException(例: シンボリックリンクループ ELOOP)がエラーログとともに再スローされることを確認する [Fact] diff --git a/FolderDiffIL4DotNet.Tests/Services/HtmlReportGenerateServiceTests.Filtering.cs b/FolderDiffIL4DotNet.Tests/Services/HtmlReportGenerateServiceTests.Filtering.cs index 00891c8d..88066e70 100644 --- a/FolderDiffIL4DotNet.Tests/Services/HtmlReportGenerateServiceTests.Filtering.cs +++ b/FolderDiffIL4DotNet.Tests/Services/HtmlReportGenerateServiceTests.Filtering.cs @@ -347,10 +347,13 @@ public void GenerateDiffReportHtml_HeaderOmitsAvailabilityTable_WhenProbeResults var html = File.ReadAllText(Path.Combine(reportDir, HtmlReportGenerateService.DIFF_REPORT_HTML_FILE_NAME)); - // Assert: no availability table when probe results are null - // プローブ結果が null の場合ツール名は出力されない - Assert.DoesNotContain("dotnet-ildasm", html); - Assert.DoesNotContain("ilspycmd", html); + // Assert: no availability section when probe results are null. Tool names may still + // appear in the built-in normalization-rule provenance list. + // プローブ結果が null の場合、可用性セクションは出力されない。ツール名自体は + // 組み込み正規化規則の由来一覧へ表示される場合がある。 + Assert.DoesNotContain("Disassembler Availability", html); + Assert.DoesNotContain("class=\"status-available\"", html); + Assert.DoesNotContain("class=\"status-unavailable\"", html); } [Fact] diff --git a/FolderDiffIL4DotNet.Tests/Services/HtmlReportGenerateServiceTests.MutationKilling.cs b/FolderDiffIL4DotNet.Tests/Services/HtmlReportGenerateServiceTests.MutationKilling.cs index d1289914..aa29516b 100644 --- a/FolderDiffIL4DotNet.Tests/Services/HtmlReportGenerateServiceTests.MutationKilling.cs +++ b/FolderDiffIL4DotNet.Tests/Services/HtmlReportGenerateServiceTests.MutationKilling.cs @@ -290,6 +290,35 @@ public void GenerateDiffReportHtml_WarningsSection_BothSha256AndTimestampWarning Assert.Contains("ts-warn.dll", html); } + /// + /// Verifies that validated configured-normalization warnings use the generic heading and HTML-safe visible escapes. + /// 検証済み設定正規化警告が汎用見出しと HTML 安全な可視エスケープを使用することを確認する。 + /// + [Fact] + [Trait("Category", "Unit")] + public void GenerateDiffReportHtml_ValidatedConfiguredNormalizationWarning_UsesGenericHeadingAndSafeEscapes() + { + var (oldDir, newDir, reportDir) = MakeDirs("warn-il-normalization"); + string warning = Assert.Single(ILOutputService.ValidateILNormalizeContainingStrings( + new[] { "buildserver1_", "buildserver1_artifact" })); + _resultLists.ILFilterWarnings.Add(warning); + + var config = CreateConfig(); + _service.GenerateDiffReportHtml( + new ReportGenerationContext(oldDir, newDir, reportDir, + appVersion: "1.0", elapsedTimeString: null, + computerName: "test-host", config, ilCache: null)); + + var html = File.ReadAllText(Path.Combine(reportDir, HtmlReportGenerateService.DIFF_REPORT_HTML_FILE_NAME)); + + Assert.Contains("IL substring configuration safety warnings (1)", html, StringComparison.Ordinal); + Assert.Contains("ILNormalizeContainingStrings", html, StringComparison.Ordinal); + Assert.Contains("buildserver1&#95;", html, StringComparison.Ordinal); + Assert.Contains("buildserver1&#95;artifact", html, StringComparison.Ordinal); + Assert.DoesNotContain("buildserver1_", html, StringComparison.Ordinal); + Assert.DoesNotContain("IL filter validation warnings", html, StringComparison.Ordinal); + } + /// /// Verifies that warnings section is absent when there are no SHA256 mismatches and no timestamp regressions. /// SHA256 ミスマッチもタイムスタンプ逆行もない場合、警告セクションが表示されないことを確認する。 @@ -384,10 +413,9 @@ public void GenerateDiffReportHtml_HeaderSection_ElapsedTimeNull_NotDisplayed() } /// - /// Verifies that when ShouldIgnoreILLinesContainingConfiguredStrings=true but the list is empty, - /// the "no strings configured" message is shown. + /// Verifies that an enabled ignore section explicitly reports an empty configured list. /// ShouldIgnoreILLinesContainingConfiguredStrings=true でリストが空の場合、 - /// 「文字列未設定」メッセージが表示されることを確認する。 + /// 設定値がないことを明示するセクションが表示されることを確認する。 /// [Fact] [Trait("Category", "Unit")] @@ -407,9 +435,87 @@ public void GenerateDiffReportHtml_HeaderSection_ILIgnoreEnabled_EmptyList_Shows var html = File.ReadAllText(Path.Combine(reportDir, HtmlReportGenerateService.DIFF_REPORT_HTML_FILE_NAME)); - // Should show the "no strings configured" message / 「文字列未設定」メッセージが表示されること - Assert.Contains("IL Ignored Strings", html); + Assert.Contains("IL Ignore Line Containing Strings", html); + Assert.Contains("Enabled, but no non-empty strings are configured.", html); + Assert.DoesNotContain("Substring to Ignore", html); + } + + [Fact] + [Trait("Category", "Unit")] + public void GenerateDiffReportHtml_HeaderSection_NormalizeValues_ShowsSection() + { + var (oldDir, newDir, reportDir) = MakeDirs("il-normalize-values"); + var builder = CreateConfigBuilder(); + builder.ShouldILNormalizeContainingConfiguredStrings = true; + builder.ILNormalizeContainingStrings = new List { "buildserver2_", "buildserver1_" }; + builder.ShouldIgnoreILLinesContainingConfiguredStrings = true; + builder.ILIgnoreLineContainingStrings = new List { "ignored-value" }; + + _service.GenerateDiffReportHtml( + new ReportGenerationContext(oldDir, newDir, reportDir, + appVersion: "1.0", elapsedTimeString: null, + computerName: "test-host", builder.Build(), ilCache: null)); + + var html = File.ReadAllText(Path.Combine(reportDir, HtmlReportGenerateService.DIFF_REPORT_HTML_FILE_NAME)); + Assert.Contains("IL Normalize Containing Strings", html); + Assert.Contains( + "Rules apply in the listed order to all IL text, preserving each matching prefix and replacing only its build-variant value.", + html); + Assert.Contains( + "Matches are replaced in the listed order with a comparison-local marker absent from both inputs; all other text remains comparable.", + html); + Assert.Contains("Observed Output From", html); + Assert.DoesNotContain("All listed rules are applied to every IL text", html); + Assert.DoesNotContain("For ilspycmd's multiline TypeLibraryTimeStampAttribute", html); + Assert.DoesNotContain("Disassembler (Observed In)", html); + Assert.DoesNotContain("it does not limit where the rule is applied", html); + Assert.Contains("dotnet-ildasm", html); + Assert.Contains("ilspycmd", html); + Assert.Contains("// Method begins at RVA 0x", html); + Assert.Contains("// Code size: ", html); + Assert.Contains("class=\"filter-table header-config-table\"", html); + Assert.Contains("Line Prefix Pattern", html); + Assert.Contains("Substring to Normalize", html); + Assert.Contains("Substring to Ignore", html); + Assert.DoesNotContain($"Replacement: {ILOutputService.CONFIGURED_NORMALIZED_VALUE}", html); + Assert.Contains("buildserver1_", html); + Assert.True( + html.IndexOf("buildserver2_", StringComparison.Ordinal) + < html.IndexOf("buildserver1_", StringComparison.Ordinal)); + Assert.DoesNotContain(""buildserver1_"", html); + Assert.Contains("ignored-value", html); + Assert.DoesNotContain(""ignored-value"", html); + Assert.DoesNotContain("Every occurrence of each configured substring", html); + Assert.DoesNotContain("comparison-local collision-free marker", html); + Assert.DoesNotContain("<nildiff:normalized:configured-value>", html); + Assert.DoesNotContain("all remaining text on the line stays unchanged and comparable", html); + Assert.DoesNotContain("The substrings are listed in application order.", html); + int builtInIndex = html.IndexOf("Built-in IL Normalization", StringComparison.Ordinal); + int normalizeIndex = html.IndexOf("IL Normalize Containing Strings", StringComparison.Ordinal); + int ignoreIndex = html.IndexOf("IL Ignore Line Containing Strings", StringComparison.Ordinal); + Assert.True(builtInIndex >= 0); + Assert.True(normalizeIndex > builtInIndex); + Assert.True(ignoreIndex > normalizeIndex); + } + + [Fact] + [Trait("Category", "Unit")] + public void GenerateDiffReportHtml_HeaderSection_NormalizeEnabled_EmptyList_ShowsNoStringsMessage() + { + var (oldDir, newDir, reportDir) = MakeDirs("il-normalize-empty"); + var builder = CreateConfigBuilder(); + builder.ShouldILNormalizeContainingConfiguredStrings = true; + builder.ILNormalizeContainingStrings = new List(); + + _service.GenerateDiffReportHtml( + new ReportGenerationContext(oldDir, newDir, reportDir, + appVersion: "1.0", elapsedTimeString: null, + computerName: "test-host", builder.Build(), ilCache: null)); + + var html = File.ReadAllText(Path.Combine(reportDir, HtmlReportGenerateService.DIFF_REPORT_HTML_FILE_NAME)); + Assert.Contains("IL Normalize Containing Strings", html); Assert.Contains("Enabled, but no non-empty strings are configured.", html); + Assert.DoesNotContain("Substring to Normalize", html); } /// diff --git a/FolderDiffIL4DotNet.Tests/Services/ILOutputCorpusTests.cs b/FolderDiffIL4DotNet.Tests/Services/ILOutputCorpusTests.cs new file mode 100644 index 00000000..79f9b85d --- /dev/null +++ b/FolderDiffIL4DotNet.Tests/Services/ILOutputCorpusTests.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using FolderDiffIL4DotNet.Common; +using FolderDiffIL4DotNet.Services; +using Xunit; + +namespace FolderDiffIL4DotNet.Tests.Services +{ + [Trait("Category", "Unit")] + public sealed class ILOutputCorpusTests + { + private static readonly string CorpusRoot = Path.Combine( + FindRepositoryRoot(), "FolderDiffIL4DotNet.Tests", "Fixtures", "ILCorpus"); + + public static IEnumerable DisassemblerFixtures() + { + yield return new object[] { "dotnet-ildasm", "dotnet-ildasm-0.12.2.il" }; + yield return new object[] { "ilspycmd", "ilspycmd-9.1.0.7988.il" }; + } + + [Theory] + [MemberData(nameof(DisassemblerFixtures))] + public void RealIlFixture_CoversRepresentativeLanguageAndMetadataShapes( + string disassembler, + string fixtureFileName) + { + var text = ReadFixture(fixtureFileName); + var lines = File.ReadAllLines(Path.Combine(CorpusRoot, fixtureFileName)); + + Assert.True(lines.Length > 500, $"{disassembler} fixture appears truncated."); + Assert.Contains("RepresentativeType`1", text, StringComparison.Ordinal); + Assert.Contains(".ctor", text, StringComparison.Ordinal); + Assert.Contains(".cctor", text, StringComparison.Ordinal); + Assert.Contains("d__", text, StringComparison.Ordinal); + Assert.Contains("d__", text, StringComparison.Ordinal); + Assert.Contains("<>c__DisplayClass", text, StringComparison.Ordinal); + Assert.Contains("Convert", text, StringComparison.Ordinal); + Assert.Contains(".property", text, StringComparison.Ordinal); + Assert.Contains(".event", text, StringComparison.Ordinal); + Assert.Contains(".field", text, StringComparison.Ordinal); + Assert.Contains("ICorpusComContract", text, StringComparison.Ordinal); + Assert.Contains("GuidAttribute", text, StringComparison.Ordinal); + Assert.Contains("InterfaceTypeAttribute", text, StringComparison.Ordinal); + Assert.Contains("DispIdAttribute", text, StringComparison.Ordinal); + } + + [Theory] + [MemberData(nameof(DisassemblerFixtures))] + public void RealIlFixture_PreservesClassAndNestedMethodStructure( + string disassembler, + string fixtureFileName) + { + var text = ReadFixture(fixtureFileName); + int classStart = text.IndexOf(".class public auto ansi sealed ILCorpus.Sample.RepresentativeType`1", StringComparison.Ordinal); + int constructor = text.IndexOf(".ctor", classStart, StringComparison.Ordinal); + int ordinaryMethod = text.IndexOf(" Add", constructor, StringComparison.Ordinal); + int asyncStateMachine = text.IndexOf("d__", classStart, StringComparison.Ordinal); + + Assert.True(classStart >= 0, $"{disassembler} fixture must contain the representative class."); + Assert.True(constructor > classStart, $"{disassembler} fixture must nest constructors in the class."); + Assert.True(ordinaryMethod > constructor, $"{disassembler} fixture must contain multiple class methods."); + Assert.True(asyncStateMachine > classStart, $"{disassembler} fixture must contain an async state-machine type."); + } + + [Fact] + public void DotNetIldasmFixture_ContainsSignedAssemblyAndAssemblyReferenceEvidence() + { + var text = ReadFixture("dotnet-ildasm-0.12.2.il"); + var project = File.ReadAllText(Path.Combine(CorpusRoot, "Source", "ILCorpus.Sample.csproj")); + + Assert.Contains("true", project, StringComparison.Ordinal); + Assert.True(File.Exists(Path.Combine(CorpusRoot, "Source", "ILCorpus.TestKey.snk"))); + Assert.Contains(".corflags 0x00000009", text, StringComparison.Ordinal); + Assert.Contains(".assembly extern System.Runtime", text, StringComparison.Ordinal); + Assert.Contains(".publickeytoken = (", text, StringComparison.Ordinal); + } + + [Fact] + public void DotNetIldasmFixture_BuiltInNormalizationUsesRealOutputLines() + { + var lines = File.ReadAllLines(Path.Combine(CorpusRoot, "dotnet-ildasm-0.12.2.il")); + var mvidLine = FindLineStartingWith(lines, Constants.IL_MVID_LINE_PREFIX); + var rvaLine = FindLineStartingWith(lines, Constants.IL_RVA_LINE_PREFIX); + var codeSizeLine = FindLineStartingWith(lines, Constants.IL_CODE_SIZE_LINE_PREFIX); + + Assert.Equal( + $"{Constants.IL_MVID_LINE_PREFIX} {ILOutputService.MVID_NORMALIZED_VALUE}", + ILOutputService.NormalizeIlLine(mvidLine, Array.Empty()).TrimStart()); + Assert.Equal( + $"{Constants.IL_RVA_LINE_PREFIX}{ILOutputService.RVA_NORMALIZED_VALUE}", + ILOutputService.NormalizeIlLine(rvaLine, Array.Empty()).TrimStart()); + Assert.Equal( + $"{Constants.IL_CODE_SIZE_LINE_PREFIX}{ILOutputService.CODE_SIZE_NORMALIZED_VALUE}", + ILOutputService.NormalizeIlLine(codeSizeLine, Array.Empty()).TrimStart()); + } + + [Fact] + public void IlspyFixture_BuiltInNormalizationUsesItsActualCommentSyntax() + { + var lines = File.ReadAllLines(Path.Combine(CorpusRoot, "ilspycmd-9.1.0.7988.il")); + var text = string.Join(Environment.NewLine, lines); + var rvaLine = FindLineStartingWith(lines, Constants.IL_ILSPY_RVA_LINE_PREFIX); + var codeSizeLine = FindLineStartingWith(lines, Constants.IL_ILSPY_CODE_SIZE_LINE_PREFIX); + + Assert.Contains("// Method begins at RVA 0x", text, StringComparison.Ordinal); + Assert.Contains("// Code size: ", text, StringComparison.Ordinal); + Assert.Contains(".custom instance void [System.Runtime]", text, StringComparison.Ordinal); + Assert.DoesNotContain(".custom instance void class [System.Runtime]", text, StringComparison.Ordinal); + Assert.Contains("abstract import beforefieldinit ILCorpus.Sample.ICorpusComContract", text, StringComparison.Ordinal); + Assert.DoesNotContain(Constants.IL_RVA_LINE_PREFIX, text, StringComparison.Ordinal); + Assert.Equal( + $"{Constants.IL_ILSPY_RVA_LINE_PREFIX}{ILOutputService.RVA_NORMALIZED_VALUE}", + ILOutputService.NormalizeIlLine(rvaLine, Array.Empty()).TrimStart()); + Assert.Equal( + $"{Constants.IL_ILSPY_CODE_SIZE_LINE_PREFIX}{ILOutputService.CODE_SIZE_NORMALIZED_VALUE}", + ILOutputService.NormalizeIlLine(codeSizeLine, Array.Empty()).TrimStart()); + } + + private static string ReadFixture(string fixtureFileName) + => File.ReadAllText(Path.Combine(CorpusRoot, fixtureFileName)); + + private static string FindLineStartingWith(IEnumerable lines, string prefix) + => lines.First(line => line.AsSpan().TrimStart().StartsWith(prefix, StringComparison.Ordinal)); + + private static string FindRepositoryRoot() + { + string? directory = AppContext.BaseDirectory; + while (directory != null) + { + if (File.Exists(Path.Combine(directory, "FolderDiffIL4DotNet.sln"))) + { + return directory; + } + + directory = Path.GetDirectoryName(directory); + } + + throw new DirectoryNotFoundException("Could not locate the repository root for IL corpus fixtures."); + } + } +} diff --git a/FolderDiffIL4DotNet.Tests/Services/ILOutputServiceTests.Precompute.cs b/FolderDiffIL4DotNet.Tests/Services/ILOutputServiceTests.Precompute.cs index 9f2c8d6b..b71f8eb0 100644 --- a/FolderDiffIL4DotNet.Tests/Services/ILOutputServiceTests.Precompute.cs +++ b/FolderDiffIL4DotNet.Tests/Services/ILOutputServiceTests.Precompute.cs @@ -63,7 +63,6 @@ public async Task DiffDotNetAssembliesAsync_WhenIlTextOutputFails_LogsRelativeAn { var config = new ConfigSettingsBuilder { - ShouldIgnoreMVID = true, IgnoredExtensions = new(), TextFileExtensions = new() }.Build(); @@ -102,7 +101,6 @@ public async Task DiffDotNetAssembliesAsync_WhenOneIlSideStaysEmpty_ThrowsAfterR { var config = new ConfigSettingsBuilder { - ShouldIgnoreMVID = true, IgnoredExtensions = new(), TextFileExtensions = new() }.Build(); @@ -151,7 +149,6 @@ public async Task DiffDotNetAssembliesAsync_WhenRawIlSideRecovers_RetriesAndWrit { var config = new ConfigSettingsBuilder { - ShouldIgnoreMVID = true, IgnoredExtensions = new(), TextFileExtensions = new() }.Build(); @@ -190,7 +187,8 @@ public async Task DiffDotNetAssembliesAsync_WhenFilteredIlSideIsEmpty_LogsWithou { var config = new ConfigSettingsBuilder { - ShouldIgnoreMVID = true, + ShouldIgnoreILLinesContainingConfiguredStrings = true, + ILIgnoreLineContainingStrings = new() { "// MVID:" }, IgnoredExtensions = new(), TextFileExtensions = new() }.Build(); @@ -226,9 +224,47 @@ public async Task DiffDotNetAssembliesAsync_WhenFilteredIlSideIsEmpty_LogsWithou Assert.Contains("WillRetry=False", warning.Message, StringComparison.Ordinal); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task DiffDotNetAssembliesAsync_ConfiguredNormalizationMarkerIsLiteral_DetectsDifference( + bool shouldOutputIlText) + { + var config = new ConfigSettingsBuilder + { + ShouldILNormalizeContainingConfiguredStrings = true, + ILNormalizeContainingStrings = new() { "buildserver1_" }, + IgnoredExtensions = new(), + TextFileExtensions = new() + }.Build(); + var logger = new TestLogger(logFileAbsolutePath: "test.log"); + var executionContext = new DiffExecutionContext( + "/tmp/fd-iloutput-old", + "/tmp/fd-iloutput-new", + "/tmp/fd-iloutput-report", + optimizeForNetworkShares: false, + detectedNetworkOld: false, + detectedNetworkNew: false); + var service = new ILOutputService( + config, + executionContext, + new NoOpIlTextOutputService(), + new NormalizationMarkerCollisionDisassembleService(), + ilCache: null, + logger); + + var result = await service.DiffDotNetAssembliesAsync( + "lib/app.dll", + "/virtual/old", + "/virtual/new", + shouldOutputIlText); + + Assert.False(result.AreEqual); + } + private sealed class NoOpIlTextOutputService : IILTextOutputService { - public Task WriteFullIlTextsAsync(string fileRelativePath, IEnumerable il1LinesMvidExcluded, IEnumerable il2LinesMvidExcluded) + public Task WriteFullIlTextsAsync(string fileRelativePath, IEnumerable filteredIl1Lines, IEnumerable filteredIl2Lines) => Task.CompletedTask; } @@ -238,17 +274,17 @@ private sealed class CapturingIlTextOutputService : IILTextOutputService public IReadOnlyList? NewLines { get; private set; } - public Task WriteFullIlTextsAsync(string fileRelativePath, IEnumerable il1LinesMvidExcluded, IEnumerable il2LinesMvidExcluded) + public Task WriteFullIlTextsAsync(string fileRelativePath, IEnumerable filteredIl1Lines, IEnumerable filteredIl2Lines) { - OldLines = il1LinesMvidExcluded.ToArray(); - NewLines = il2LinesMvidExcluded.ToArray(); + OldLines = filteredIl1Lines.ToArray(); + NewLines = filteredIl2Lines.ToArray(); return Task.CompletedTask; } } private sealed class ThrowingIlTextOutputService : IILTextOutputService { - public Task WriteFullIlTextsAsync(string fileRelativePath, IEnumerable il1LinesMvidExcluded, IEnumerable il2LinesMvidExcluded) + public Task WriteFullIlTextsAsync(string fileRelativePath, IEnumerable filteredIl1Lines, IEnumerable filteredIl2Lines) => throw new DirectoryNotFoundException("missing IL output folder"); } @@ -344,5 +380,24 @@ private sealed class FilteredEmptyOldDisassembleService : IDotNetDisassembleServ public Task PrefetchIlCacheAsync(IEnumerable paths, int maxParallel, CancellationToken cancellationToken = default) => Task.CompletedTask; } + + private sealed class NormalizationMarkerCollisionDisassembleService : IDotNetDisassembleService + { + public Task<(string oldIlText, string oldCommandString, string newIlText, string newCommandString)> DisassemblePairWithSameDisassemblerAsync( + string oldPath, string newPath, CancellationToken cancellationToken = default) + => Task.FromResult<(string, string, string, string)>((string.Empty, string.Empty, string.Empty, string.Empty)); + + public Task<(IReadOnlyList oldIlLines, string oldCommandString, IReadOnlyList newIlLines, string newCommandString)> DisassemblePairAsLinesWithSameDisassemblerAsync( + string oldPath, string newPath, CancellationToken cancellationToken = default) + { + IReadOnlyList oldLines = new[] { "ldstr \"buildserver1_\"" }; + IReadOnlyList newLines = new[] { $"ldstr \"{ILOutputService.CONFIGURED_NORMALIZED_VALUE}\"" }; + return Task.FromResult<(IReadOnlyList, string, IReadOnlyList, string)>( + (oldLines, "dotnet ildasm old.dll (version: 1.0.0)", newLines, "dotnet ildasm new.dll (version: 1.0.0)")); + } + + public Task PrefetchIlCacheAsync(IEnumerable paths, int maxParallel, CancellationToken cancellationToken = default) + => Task.CompletedTask; + } } } diff --git a/FolderDiffIL4DotNet.Tests/Services/ILOutputServiceTests.cs b/FolderDiffIL4DotNet.Tests/Services/ILOutputServiceTests.cs index 8af16b9e..c7c6ac71 100644 --- a/FolderDiffIL4DotNet.Tests/Services/ILOutputServiceTests.cs +++ b/FolderDiffIL4DotNet.Tests/Services/ILOutputServiceTests.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Reflection; using System.Threading.Tasks; +using FolderDiffIL4DotNet.Common; using FolderDiffIL4DotNet.Models; using FolderDiffIL4DotNet.Services; using FolderDiffIL4DotNet.Services.Caching; @@ -18,13 +19,6 @@ namespace FolderDiffIL4DotNet.Tests.Services /// public sealed partial class ILOutputServiceTests { - [Fact] - public void ShouldExcludeIlLine_MvidPrefix_IsAlwaysExcluded() - { - var result = InvokeShouldExcludeIlLine("// MVID: 1234", shouldIgnoreContainingStrings: false, new List()); - Assert.True(result); - } - [Fact] public void ShouldExcludeIlLine_ContainsConfiguredString_ExcludedOnlyWhenEnabled() { @@ -36,18 +30,373 @@ public void ShouldExcludeIlLine_ContainsConfiguredString_ExcludedOnlyWhenEnabled } [Fact] - public void GetNormalizedIlIgnoreContainingStrings_RemovesEmptyTrimAndDuplicates() + public void ConfiguredSubstringHelper_GetEffectiveIgnoreLineSubstrings_RemovesEmptyTrimAndDuplicates() { var config = new ConfigSettingsBuilder { ILIgnoreLineContainingStrings = new List { "buildserver", " buildpath ", "", "buildserver", " " } }.Build(); - var result = InvokeGetNormalizedIlIgnoreContainingStrings(config); + var result = ILConfiguredSubstringHelper.GetEffectiveIgnoreLineSubstrings(config.ILIgnoreLineContainingStrings); Assert.Equal(new[] { "buildserver", "buildpath" }, result); } + [Fact] + public void ConfiguredSubstringHelper_GetEffectiveNormalizationSubstrings_PreservesMeaningfulWhitespace() + { + var configuredStrings = new List { " token ", "", " ", " token ", "token" }; + + var result = ILConfiguredSubstringHelper.GetEffectiveNormalizationSubstrings(configuredStrings); + + Assert.Equal(new[] { " token ", "token" }, result); + } + + [Fact] + public void ConfiguredSubstringHelper_GetEffectiveNormalizationSubstrings_RejectsRuntimeCountAboveLimit() + { + var configuredStrings = Enumerable.Range( + 0, + ConfigSettings.MaxILNormalizeContainingStringsCount + 1) + .Select(index => $"normalization-{index}") + .ToList(); + + var exception = Assert.Throws( + () => ILConfiguredSubstringHelper.GetEffectiveNormalizationSubstrings(configuredStrings)); + + Assert.Contains("Invalid runtime configuration", exception.Message, StringComparison.Ordinal); + Assert.Contains("at most 256 values", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ConfiguredSubstringHelper_GetEffectiveNormalizationSubstrings_RejectsRuntimeValueAboveLengthLimit() + { + var configuredStrings = new[] + { + new string('x', ConfigSettings.MaxILNormalizeContainingStringLength + 1) + }; + + var exception = Assert.Throws( + () => ILConfiguredSubstringHelper.GetEffectiveNormalizationSubstrings(configuredStrings)); + + Assert.Contains("Invalid runtime configuration", exception.Message, StringComparison.Ordinal); + Assert.Contains("4096 Unicode characters or fewer", exception.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData("// MVID: 12345678-1234-1234-1234-123456789ABC", "// MVID: ")] + [InlineData(" // Method begins at Relative Virtual Address (RVA) 0x2050", " // Method begins at Relative Virtual Address (RVA) 0x")] + [InlineData("// Code size 14 (0xe)", "// Code size ")] + [InlineData(".custom instance void class [System.Windows.Forms]System.Windows.Forms.AxHost/TypeLibraryTimeStampAttribute::.ctor(string) = ( 01 00 08 )", ".custom instance void class [System.Windows.Forms]System.Windows.Forms.AxHost/TypeLibraryTimeStampAttribute::.ctor(string) = ( ")] + [InlineData(".custom instance void [System.Windows.Forms]System.Windows.Forms.AxHost/TypeLibraryTimeStampAttribute::.ctor(string) = ( 01 00 08 )", ".custom instance void [System.Windows.Forms]System.Windows.Forms.AxHost/TypeLibraryTimeStampAttribute::.ctor(string) = ( ")] + public void NormalizeIlLine_KnownBuildValues_ReplacesOnlyVariableSuffix(string line, string expected) + { + Assert.Equal(expected, ILOutputService.NormalizeIlLine(line, Array.Empty())); + } + + [Fact] + public void NormalizeIlLine_KnownPrefixInsideStringLiteral_DoesNotNormalize() + { + const string line = "ldstr \"// Code size 14 (0xe)\""; + + Assert.Equal(line, ILOutputService.NormalizeIlLine(line, Array.Empty())); + } + + [Fact] + public void StreamingFilteredSequenceEqual_ConfiguredNormalization_PreservesSurroundingDifferences() + { + var normalizeStrings = new[] { "buildserver1_", "buildserver2_" }; + + Assert.True(ILOutputService.StreamingFilteredSequenceEqual( + new[] { "ldstr buildserver1_artifact" }, + new[] { "ldstr buildserver2_artifact" }, + false, + Array.Empty(), + ilNormalizeContainingStrings: normalizeStrings)); + Assert.False(ILOutputService.StreamingFilteredSequenceEqual( + new[] { "ldstr old-buildserver1_artifact" }, + new[] { "ldstr new-buildserver2_artifact" }, + false, + Array.Empty(), + ilNormalizeContainingStrings: normalizeStrings)); + } + + [Fact] + public void StreamingFilteredSequenceEqual_ConfiguredNormalization_DoesNotCollideWithLiteralMarker() + { + Assert.False(ILOutputService.StreamingFilteredSequenceEqual( + new[] { "ldstr \"buildserver1_\"" }, + new[] { $"ldstr \"{ILOutputService.CONFIGURED_NORMALIZED_VALUE}\"" }, + false, + Array.Empty(), + ilNormalizeContainingStrings: new[] { "buildserver1_" })); + } + + [Fact] + public void StreamingFilteredSequenceEqual_ConfiguredNormalization_DoesNotRewriteInsertedMarker() + { + Assert.True(ILOutputService.StreamingFilteredSequenceEqual( + new[] { "ldstr foo" }, + new[] { "ldstr configured" }, + false, + Array.Empty(), + ilNormalizeContainingStrings: new[] { "foo", "configured" })); + } + + [Fact] + public void NormalizeIlLine_ConfiguredReplacementAtPerLineLimit_Succeeds() + { + string line = new string('x', ILOutputService.MAX_CONFIGURED_NORMALIZATION_REPLACEMENTS_PER_LINE); + + string result = ILOutputService.NormalizeIlLine(line, new[] { "x" }); + + Assert.Equal( + checked(ILOutputService.MAX_CONFIGURED_NORMALIZATION_REPLACEMENTS_PER_LINE + * ILOutputService.CONFIGURED_NORMALIZED_VALUE.Length), + result.Length); + Assert.DoesNotContain('x', result); + } + + [Fact] + public void NormalizeIlLine_ConfiguredReplacementAbovePerLineLimit_ThrowsInvalidDataException() + { + string line = new string( + 'x', + ILOutputService.MAX_CONFIGURED_NORMALIZATION_REPLACEMENTS_PER_LINE + 1); + + InvalidDataException exception = Assert.Throws( + () => ILOutputService.NormalizeIlLine(line, new[] { "x" })); + + Assert.Equal( + "ILNormalizeContainingStrings: configured IL normalization exceeded the per-line replacement limit of 65536 matches.", + exception.Message); + } + + [Fact] + public void NormalizeIlLine_LaterRuleExceedsCumulativePerLineReplacementLimit_ThrowsInvalidDataException() + { + string line = new string( + 'x', + ILOutputService.MAX_CONFIGURED_NORMALIZATION_REPLACEMENTS_PER_LINE) + + "y"; + + InvalidDataException exception = Assert.Throws( + () => ILOutputService.NormalizeIlLine(line, new[] { "x", "y" })); + + Assert.Equal( + "ILNormalizeContainingStrings: configured IL normalization exceeded the per-line replacement limit of 65536 matches.", + exception.Message); + } + + [Fact] + public void NormalizeIlLine_ConfiguredReplacementAtOutputLimit_Succeeds() + { + string line = new string( + 'z', + ILOutputService.MAX_CONFIGURED_NORMALIZED_LINE_UTF16_CODE_UNITS + - ILOutputService.CONFIGURED_NORMALIZED_VALUE.Length) + + "x"; + + string result = ILOutputService.NormalizeIlLine(line, new[] { "x" }); + + Assert.Equal(ILOutputService.MAX_CONFIGURED_NORMALIZED_LINE_UTF16_CODE_UNITS, result.Length); + } + + [Fact] + public void NormalizeIlLine_IntermediateOutputAboveLimitButFinalOutputBelowLimit_Succeeds() + { + const int shrinkingTargetLength = 100; + string shrinkingTarget = new string('z', shrinkingTargetLength); + string line = "a" + + shrinkingTarget + + new string( + 'q', + ILOutputService.MAX_CONFIGURED_NORMALIZED_LINE_UTF16_CODE_UNITS + - shrinkingTargetLength + - 1); + + string result = ILOutputService.NormalizeIlLine( + line, + new[] { "a", shrinkingTarget }); + + Assert.Equal( + ILOutputService.MAX_CONFIGURED_NORMALIZED_LINE_UTF16_CODE_UNITS + - shrinkingTargetLength + - 1 + + (2 * ILOutputService.CONFIGURED_NORMALIZED_VALUE.Length), + result.Length); + Assert.StartsWith( + ILOutputService.CONFIGURED_NORMALIZED_VALUE + + ILOutputService.CONFIGURED_NORMALIZED_VALUE, + result, + StringComparison.Ordinal); + } + + [Fact] + public void NormalizeIlLine_ConfiguredReplacementAboveOutputLimit_ThrowsBeforeMaterializingOutput() + { + string line = new string( + 'z', + ILOutputService.MAX_CONFIGURED_NORMALIZED_LINE_UTF16_CODE_UNITS + - ILOutputService.CONFIGURED_NORMALIZED_VALUE.Length + + 1) + + "x"; + + InvalidDataException exception = Assert.Throws( + () => ILOutputService.NormalizeIlLine(line, new[] { "x" })); + + Assert.Equal( + "ILNormalizeContainingStrings: configured IL normalization would exceed the per-line output limit of 4194304 UTF-16 code units.", + exception.Message); + } + + [Fact] + public void NormalizeIlLine_HugeLineWithoutConfiguredMatch_IsReturnedWithoutApplyingOutputLimit() + { + string line = new string( + 'z', + ILOutputService.MAX_CONFIGURED_NORMALIZED_LINE_UTF16_CODE_UNITS + 1); + + string result = ILOutputService.NormalizeIlLine(line, new[] { "x" }); + + Assert.Same(line, result); + } + + [Fact] + public void FilterIlLines_ConfiguredNormalization_SelectsFirstUnusedMarkerSuffix() + { + var result = ILOutputService.FilterIlLines( + new[] + { + $"ldstr {ILOutputService.CONFIGURED_NORMALIZED_VALUE}", + "ldstr ", + "ldstr normalize-me" + }, + false, + Array.Empty(), + ilNormalizeContainingStrings: new[] { "normalize-me" }); + + Assert.Equal("ldstr ", result[2]); + } + + [Fact] + public void FilterIlLines_ConfiguredNormalization_WritesStablePlaceholder() + { + var result = ILOutputService.FilterIlLines( + new[] { "ldstr buildserver1_artifact" }, + false, + Array.Empty(), + ilNormalizeContainingStrings: new[] { "buildserver1_" }); + + Assert.Equal(new[] { "ldstr artifact" }, result); + } + + [Fact] + public void StreamingFilteredSequenceEqual_KnownBuildValues_NormalizesOutsideCreatorMode() + { + var oldLines = new[] + { + "// Method begins at Relative Virtual Address (RVA) 0x2050", + "// Code size 14 (0xe)" + }; + var newLines = new[] + { + "// Method begins at Relative Virtual Address (RVA) 0x3070", + "// Code size 18 (0x12)" + }; + + Assert.True(ILOutputService.StreamingFilteredSequenceEqual( + oldLines, newLines, false, Array.Empty(), Array.Empty())); + } + + [Fact] + public void StreamingFilteredSequenceEqual_IlspyMultilineTypeLibraryTimestamp_NormalizesWholeBlob() + { + var oldLines = new[] + { + Constants.IL_ILSPY_TYPE_LIBRARY_TIMESTAMP_LINE_PREFIX, + " 01 00 08 6F 6C 64 2D 62 75 69 6C 64 00 00", + ")", + "ret" + }; + var newLines = new[] + { + Constants.IL_ILSPY_TYPE_LIBRARY_TIMESTAMP_LINE_PREFIX, + " 01 00 12 6E 65 77 2D 62 75 69 6C 64 2D 76 61 6C", + " 75 65 00 00", + ")", + "ret" + }; + + Assert.True(ILOutputService.StreamingFilteredSequenceEqual( + oldLines, newLines, false, Array.Empty(), Array.Empty())); + } + + [Fact] + public void FilterIlLines_IlspyMultilineTypeLibraryTimestamp_WritesSingleStableMarker() + { + var lines = new[] + { + " " + Constants.IL_ILSPY_TYPE_LIBRARY_TIMESTAMP_LINE_PREFIX, + " 01 00 08 62 75 69 6C 64 2D 69 64 00 00", + " )", + " ret" + }; + + var result = ILOutputService.FilterIlLines( + lines, false, Array.Empty(), Array.Empty()); + + var expected = new[] + { + " " + Constants.IL_ILSPY_TYPE_LIBRARY_TIMESTAMP_LINE_PREFIX + + " " + ILOutputService.TYPE_LIBRARY_TIMESTAMP_NORMALIZED_VALUE, + " ret" + }; + Assert.Equal(expected, result); + } + + [Fact] + public void StreamingFilteredSequenceEqual_UnterminatedIlspyTimestamp_DoesNotConsumeRemainingIl() + { + var oldLines = new[] + { + Constants.IL_ILSPY_TYPE_LIBRARY_TIMESTAMP_LINE_PREFIX, + " 01 00 01", + "ret" + }; + var newLines = new[] + { + Constants.IL_ILSPY_TYPE_LIBRARY_TIMESTAMP_LINE_PREFIX, + " 01 00 02", + "ret" + }; + + Assert.False(ILOutputService.StreamingFilteredSequenceEqual( + oldLines, newLines, false, Array.Empty(), Array.Empty())); + } + + [Fact] + public void StreamingFilteredSequenceEqual_InvalidIlspyTimestampPayload_DoesNotConsumeLaterClosingParenthesis() + { + var oldLines = new[] + { + Constants.IL_ILSPY_TYPE_LIBRARY_TIMESTAMP_LINE_PREFIX, + "not a byte payload", + ")", + "ret" + }; + var newLines = new[] + { + Constants.IL_ILSPY_TYPE_LIBRARY_TIMESTAMP_LINE_PREFIX, + "different non-payload IL", + ")", + "ret" + }; + + Assert.False(ILOutputService.StreamingFilteredSequenceEqual( + oldLines, newLines, false, Array.Empty(), Array.Empty())); + } + [Theory] [InlineData("dotnet ildasm sample.dll (version: 9.0.0)", "dotnet-ildasm (version: 9.0.0)")] [InlineData("ilspycmd -il sample.dll (version: 8.2.1)", "ilspycmd (version: 8.2.1)")] @@ -141,10 +490,10 @@ public void SplitAndFilterIlLines_CombinesSplitAndFilter_MatchesSplitThenWhereBe var method = typeof(ILOutputService).GetMethod("SplitAndFilterIlLines", BindingFlags.Static | BindingFlags.NonPublic); Assert.NotNull(method); - var result = (List)method.Invoke(null, new object[] { ilText, false, ignoreStrings, true }); + var result = (List)method.Invoke(null, new object[] { ilText, false, ignoreStrings, Array.Empty() }); - // MVID lines should be excluded; non-MVID lines retained (including empty trailing line from final \n) - Assert.DoesNotContain(result, line => line.StartsWith("// MVID:", StringComparison.Ordinal)); + // MVID lines should be retained with normalized values. + Assert.Equal(2, result.Count(line => line == "// MVID: ")); Assert.Contains("class Foo {", result); Assert.Contains("}", result); Assert.Contains(" return 0", result); @@ -158,11 +507,39 @@ public void SplitAndFilterIlLines_WithConfiguredIgnoreStrings_ExcludesMatchingLi var method = typeof(ILOutputService).GetMethod("SplitAndFilterIlLines", BindingFlags.Static | BindingFlags.NonPublic); Assert.NotNull(method); - var result = (List)method.Invoke(null, new object[] { ilText, true, ignoreStrings, true }); + var result = (List)method.Invoke(null, new object[] { ilText, true, ignoreStrings, Array.Empty() }); Assert.Equal(new[] { "line1", "line3", "" }, result); } + [Fact] + public void SplitAndFilterIlLines_IlspyMultilineTypeLibraryTimestamp_WritesSingleStableMarker() + { + string ilText = string.Join( + '\n', + Constants.IL_ILSPY_TYPE_LIBRARY_TIMESTAMP_LINE_PREFIX, + " 01 00 08 62 75 69 6C 64 2D 69 64 00 00", + ")", + "ret"); + var method = typeof(ILOutputService).GetMethod( + "SplitAndFilterIlLines", + BindingFlags.Static | BindingFlags.NonPublic); + + Assert.NotNull(method); + var result = (List)method.Invoke( + null, + new object[] { ilText, false, Array.Empty(), Array.Empty() }); + + Assert.Equal( + new[] + { + Constants.IL_ILSPY_TYPE_LIBRARY_TIMESTAMP_LINE_PREFIX + + " " + ILOutputService.TYPE_LIBRARY_TIMESTAMP_NORMALIZED_VALUE, + "ret" + }, + result); + } + [Fact] public void PreSeedFileHash_WhenCacheIsNull_DoesNotThrow() { @@ -195,21 +572,12 @@ public async Task PrecomputeAsync_WithCacheDisabled_ReturnsWithoutThrowing() private static bool InvokeShouldExcludeIlLine(string line, bool shouldIgnoreContainingStrings, IReadOnlyCollection ilIgnoreContainingStrings) { - // Pass shouldIgnoreMVID=true as the default to match previous behavior / 従来の動作に合わせて shouldIgnoreMVID=true をデフォルトで渡す var method = typeof(ILOutputService).GetMethod("ShouldExcludeIlLine", BindingFlags.Static | BindingFlags.NonPublic); Assert.NotNull(method); - var result = method.Invoke(null, new object[] { line, shouldIgnoreContainingStrings, ilIgnoreContainingStrings, true }); + var result = method.Invoke(null, new object[] { line, shouldIgnoreContainingStrings, ilIgnoreContainingStrings }); return Assert.IsType(result); } - private static List InvokeGetNormalizedIlIgnoreContainingStrings(ConfigSettings config) - { - var method = typeof(ILOutputService).GetMethod("GetNormalizedIlIgnoreContainingStrings", BindingFlags.Static | BindingFlags.NonPublic); - Assert.NotNull(method); - var result = method.Invoke(null, new object[] { config }); - return Assert.IsType>(result); - } - // --- StreamingFilteredSequenceEqual tests / StreamingFilteredSequenceEqual テスト --- [Fact] @@ -219,7 +587,7 @@ public void StreamingFilteredSequenceEqual_IdenticalLines_ReturnsTrue() var lines1 = new List { "class Foo {", "}", " return 0" }; var lines2 = new List { "class Foo {", "}", " return 0" }; - var result = ILOutputService.StreamingFilteredSequenceEqual(lines1, lines2, false, new List()); + var result = ILOutputService.StreamingFilteredSequenceEqual(lines1, lines2, false, new List(), Array.Empty()); Assert.True(result); } @@ -231,7 +599,7 @@ public void StreamingFilteredSequenceEqual_DifferentLines_ReturnsFalse() var lines1 = new List { "class Foo {", " return 0" }; var lines2 = new List { "class Foo {", " return 1" }; - var result = ILOutputService.StreamingFilteredSequenceEqual(lines1, lines2, false, new List()); + var result = ILOutputService.StreamingFilteredSequenceEqual(lines1, lines2, false, new List(), Array.Empty()); Assert.False(result); } @@ -243,7 +611,7 @@ public void StreamingFilteredSequenceEqual_SkipsExcludedMvidLines() var lines1 = new List { "// MVID: ABC", "class Foo {", "}" }; var lines2 = new List { "// MVID: XYZ", "class Foo {", "}" }; - var result = ILOutputService.StreamingFilteredSequenceEqual(lines1, lines2, false, new List()); + var result = ILOutputService.StreamingFilteredSequenceEqual(lines1, lines2, false, new List(), Array.Empty()); Assert.True(result); } @@ -256,7 +624,7 @@ public void StreamingFilteredSequenceEqual_SkipsConfiguredIgnoreStrings() var lines2 = new List { "class Foo {", "different buildserver line", "}" }; var ignoreStrings = new List { "buildserver" }; - var result = ILOutputService.StreamingFilteredSequenceEqual(lines1, lines2, true, ignoreStrings); + var result = ILOutputService.StreamingFilteredSequenceEqual(lines1, lines2, true, ignoreStrings, Array.Empty()); Assert.True(result); } @@ -268,7 +636,7 @@ public void StreamingFilteredSequenceEqual_DifferentLengthsAfterFilter_ReturnsFa var lines1 = new List { "class Foo {", "}" }; var lines2 = new List { "class Foo {", "}", "extra line" }; - var result = ILOutputService.StreamingFilteredSequenceEqual(lines1, lines2, false, new List()); + var result = ILOutputService.StreamingFilteredSequenceEqual(lines1, lines2, false, new List(), Array.Empty()); Assert.False(result); } @@ -278,21 +646,21 @@ public void StreamingFilteredSequenceEqual_DifferentLengthsAfterFilter_ReturnsFa public void StreamingFilteredSequenceEqual_BothEmpty_ReturnsTrue() { var result = ILOutputService.StreamingFilteredSequenceEqual( - new List(), new List(), false, new List()); + new List(), new List(), false, new List(), Array.Empty()); Assert.True(result); } [Fact] [Trait("Category", "Unit")] - public void StreamingFilteredSequenceEqual_AllLinesExcluded_ReturnsTrue() + public void StreamingFilteredSequenceEqual_DifferentMvidLineCounts_ReturnsFalse() { var lines1 = new List { "// MVID: A", "// MVID: B" }; var lines2 = new List { "// MVID: X" }; - var result = ILOutputService.StreamingFilteredSequenceEqual(lines1, lines2, false, new List()); + var result = ILOutputService.StreamingFilteredSequenceEqual(lines1, lines2, false, new List(), Array.Empty()); - Assert.True(result); + Assert.False(result); } // --- BlockAwareSequenceEqual tests / ブロック単位比較テスト --- @@ -491,37 +859,37 @@ public void BlockAwareSequenceEqual_OneMethodContentChangedToMatchAnother_Return Assert.False(ILOutputService.BlockAwareSequenceEqual(lines1, lines2)); } - // --- ValidateILFilterStrings tests / ValidateILFilterStrings テスト --- + // --- Configured IL substring validation tests / 設定 IL 部分文字列検証テスト --- [Fact] [Trait("Category", "Unit")] - public void ValidateILFilterStrings_NullInput_ReturnsEmpty() + public void ValidateILIgnoreContainingStrings_NullInput_ReturnsEmpty() { - var result = ILOutputService.ValidateILFilterStrings(null!); + var result = ILOutputService.ValidateILIgnoreContainingStrings(null!); Assert.Empty(result); } [Fact] [Trait("Category", "Unit")] - public void ValidateILFilterStrings_EmptyInput_ReturnsEmpty() + public void ValidateILIgnoreContainingStrings_EmptyInput_ReturnsEmpty() { - var result = ILOutputService.ValidateILFilterStrings(new List()); + var result = ILOutputService.ValidateILIgnoreContainingStrings(new List()); Assert.Empty(result); } [Fact] [Trait("Category", "Unit")] - public void ValidateILFilterStrings_AllLongStrings_ReturnsEmpty() + public void ValidateILIgnoreContainingStrings_AllLongStrings_ReturnsEmpty() { - var result = ILOutputService.ValidateILFilterStrings(new List { "buildserver", "// MVID" }); + var result = ILOutputService.ValidateILIgnoreContainingStrings(new List { "buildserver", "// MVID" }); Assert.Empty(result); } [Fact] [Trait("Category", "Unit")] - public void ValidateILFilterStrings_ShortString_ReturnsWarning() + public void ValidateILIgnoreContainingStrings_ShortString_ReturnsWarning() { - var result = ILOutputService.ValidateILFilterStrings(new List { "ret" }); + var result = ILOutputService.ValidateILIgnoreContainingStrings(new List { "ret" }); Assert.Single(result); Assert.Contains("ret", result[0]); Assert.Contains("3 chars", result[0]); @@ -529,9 +897,9 @@ public void ValidateILFilterStrings_ShortString_ReturnsWarning() [Fact] [Trait("Category", "Unit")] - public void ValidateILFilterStrings_MixedLengths_ReturnsWarningsForShortOnly() + public void ValidateILIgnoreContainingStrings_MixedLengths_ReturnsWarningsForShortOnly() { - var result = ILOutputService.ValidateILFilterStrings(new List { "ab", "buildserver", "x", "longstring" }); + var result = ILOutputService.ValidateILIgnoreContainingStrings(new List { "ab", "buildserver", "x", "longstring" }); Assert.Equal(2, result.Count); Assert.Contains(result, w => w.Contains("\"ab\"")); Assert.Contains(result, w => w.Contains("\"x\"")); @@ -539,26 +907,174 @@ public void ValidateILFilterStrings_MixedLengths_ReturnsWarningsForShortOnly() [Fact] [Trait("Category", "Unit")] - public void ValidateILFilterStrings_ExactlyMinLength_NoWarning() + public void ValidateILIgnoreContainingStrings_ExactlyMinLength_NoWarning() { // 4 chars is the minimum length (IL_FILTER_STRING_MIN_LENGTH = 4), so should pass // 4 文字は最小長(IL_FILTER_STRING_MIN_LENGTH = 4)なので警告なし - var result = ILOutputService.ValidateILFilterStrings(new List { "abcd" }); + var result = ILOutputService.ValidateILIgnoreContainingStrings(new List { "abcd" }); + Assert.Empty(result); + } + + [Fact] + [Trait("Category", "Unit")] + public void ValidateILNormalizeContainingStrings_ShortString_ReturnsNormalizationWarning() + { + var result = ILOutputService.ValidateILNormalizeContainingStrings(new List { "abc", "buildserver" }); + + string warning = Assert.Single(result); + Assert.Contains("ILNormalizeContainingStrings", warning); + Assert.Contains("\"abc\"", warning); + Assert.Contains("3 chars", warning); + Assert.Contains("normalize legitimate IL content", warning); + } + + [Fact] + [Trait("Category", "Unit")] + public void ValidateILNormalizeContainingStrings_SupplementaryCharacters_UsesRuneCount() + { + var result = ILOutputService.ValidateILNormalizeContainingStrings( + new List { "\U0001F600\U0001F603" }); + + string warning = Assert.Single(result); + Assert.Contains("😀😃", warning, StringComparison.Ordinal); + Assert.Contains("2 chars", warning, StringComparison.Ordinal); + } + + [Fact] + [Trait("Category", "Unit")] + public void ValidateILIgnoreContainingStrings_SupplementaryCharacters_UsesRuneCount() + { + var result = ILOutputService.ValidateILIgnoreContainingStrings( + new List { "\U0001F600\U0001F603" }); + + string warning = Assert.Single(result); + Assert.Contains("😀😃", warning, StringComparison.Ordinal); + Assert.Contains("2 chars", warning, StringComparison.Ordinal); + } + + [Fact] + [Trait("Category", "Unit")] + public void ValidateILNormalizeContainingStrings_UnsafeValues_UsesVisibleCommonMarkSafeEscapes() + { + const string unsafeValue = "safe\r\n\t\\\"#*_[x](y)\u001B\u200D\U0001F600"; + const string escapedValue = "safe\r\n\t\\"#*_[x](y)<z>\u001B\u200D😀"; + var result = ILOutputService.ValidateILNormalizeContainingStrings( + new List { unsafeValue, unsafeValue, $"prefix{unsafeValue}suffix" }); + + Assert.Equal(2, result.Count); + Assert.Contains(result, warning => warning.Contains("configured more than once", StringComparison.Ordinal)); + Assert.Contains(result, warning => warning.Contains("overlap by containment", StringComparison.Ordinal)); + Assert.All(result, warning => + { + Assert.Contains(escapedValue, warning, StringComparison.Ordinal); + Assert.DoesNotContain("\r", warning, StringComparison.Ordinal); + Assert.DoesNotContain("\n", warning, StringComparison.Ordinal); + Assert.DoesNotContain("\t", warning, StringComparison.Ordinal); + Assert.DoesNotContain("\u001B", warning, StringComparison.Ordinal); + Assert.DoesNotContain("\u200D", warning, StringComparison.Ordinal); + Assert.DoesNotContain("\\", warning, StringComparison.Ordinal); + Assert.DoesNotContain("\U0001F600", warning, StringComparison.Ordinal); + }); + } + + [Fact] + [Trait("Category", "Unit")] + public void ValidateILNormalizeContainingStrings_UnpairedSurrogatesAndCombiningMarks_PreservesVisibleCodePoints() + { + const string unsafeValue = "a\uD800b\uDC00c\uFFFDd\u0301e\uFE0F"; + const string escapedValue = "a\uD800b\uDC00c�d\u0301e\uFE0F"; + + var result = ILOutputService.ValidateILNormalizeContainingStrings( + new List { unsafeValue, unsafeValue }); + + string warning = Assert.Single(result); + Assert.Contains(escapedValue, warning, StringComparison.Ordinal); + Assert.DoesNotContain("\uD800", warning, StringComparison.Ordinal); + Assert.DoesNotContain("\uDC00", warning, StringComparison.Ordinal); + Assert.DoesNotContain("\uFFFD", warning, StringComparison.Ordinal); + Assert.DoesNotContain("\u0301", warning, StringComparison.Ordinal); + Assert.DoesNotContain("\uFE0F", warning, StringComparison.Ordinal); + } + + [Fact] + [Trait("Category", "Unit")] + public void ValidateILNormalizeContainingStrings_DuplicateAndContainment_ReturnsDistinctWarnings() + { + var result = ILOutputService.ValidateILNormalizeContainingStrings( + new List { "buildserver1_", "buildserver1_artifact", "buildserver1_", "independent" }); + + Assert.Equal(2, result.Count); + Assert.Contains(result, warning => warning.Contains("configured more than once", StringComparison.Ordinal)); + Assert.Contains(result, warning => warning.Contains("overlap by containment", StringComparison.Ordinal) + && warning.Contains("listed order", StringComparison.Ordinal) + && warning.Contains("inserted markers are not reprocessed", StringComparison.Ordinal)); + } + + [Fact] + [Trait("Category", "Unit")] + public void ValidateILNormalizeContainingStrings_NonOverlappingValues_ReturnsNoRelationshipWarning() + { + var result = ILOutputService.ValidateILNormalizeContainingStrings( + new List { "buildserver1_", "buildserver2_", @"\temp\develop\" }); + Assert.Empty(result); } + [Fact] + [Trait("Category", "Unit")] + public void ValidateILNormalizeContainingStrings_ManyOverlaps_CapsDetailsAndSummarizesSuppressedWarnings() + { + var configuredStrings = Enumerable.Range(4, ConfigSettings.MaxILNormalizeContainingStringsCount) + .Select(length => new string('a', length)) + .ToList(); + + var result = ILOutputService.ValidateILNormalizeContainingStrings(configuredStrings); + + Assert.Equal(101, result.Count); + Assert.All(result.Take(100), warning => Assert.Contains("overlap by containment", warning, StringComparison.Ordinal)); + Assert.Contains("first 100 safety warning details", result[100], StringComparison.Ordinal); + Assert.Contains("32540 additional warnings were suppressed", result[100], StringComparison.Ordinal); + } + + [Fact] + [Trait("Category", "Unit")] + public void ValidateILNormalizeContainingStrings_ManyShortValues_SummaryNamesShortValues() + { + var configuredStrings = Enumerable.Range(0, ConfigSettings.MaxILNormalizeContainingStringsCount) + .Select(index => index.ToString("X3", System.Globalization.CultureInfo.InvariantCulture)) + .ToList(); + + var result = ILOutputService.ValidateILNormalizeContainingStrings(configuredStrings); + + Assert.Equal(101, result.Count); + Assert.All(result.Take(100), warning => Assert.Contains("is very short", warning, StringComparison.Ordinal)); + Assert.Contains("156 additional warnings were suppressed", result[100], StringComparison.Ordinal); + Assert.Contains("Reduce short, duplicate, or overlapping values", result[100], StringComparison.Ordinal); + } + + [Fact] + [Trait("Category", "Unit")] + public void NormalizeIlLine_OverlappingConfiguredValues_FollowsListOrder() + { + string shorterFirst = ILOutputService.NormalizeIlLine("ldstr abcd", new[] { "abc", "abcd" }); + string longerFirst = ILOutputService.NormalizeIlLine("ldstr abcd", new[] { "abcd", "abc" }); + + Assert.Equal($"ldstr {ILOutputService.CONFIGURED_NORMALIZED_VALUE}d", shorterFirst); + Assert.Equal($"ldstr {ILOutputService.CONFIGURED_NORMALIZED_VALUE}", longerFirst); + } + // --- FilterIlLines tests / FilterIlLines テスト --- [Fact] [Trait("Category", "Unit")] - public void FilterIlLines_RemovesMvidAndConfiguredStrings() + public void FilterIlLines_NormalizesMvidAndRemovesConfiguredStrings() { var lines = new List { "// MVID: ABC", "class Foo {", "buildpath stuff", "}" }; var ignoreStrings = new List { "buildpath" }; - var result = ILOutputService.FilterIlLines(lines, true, ignoreStrings); + var result = ILOutputService.FilterIlLines(lines, true, ignoreStrings, Array.Empty()); - Assert.Equal(new[] { "class Foo {", "}" }, result); + Assert.Equal(new[] { "// MVID: ", "class Foo {", "}" }, result); } [Fact] @@ -567,7 +1083,7 @@ public void FilterIlLines_NoExclusions_ReturnsAllLines() { var lines = new List { "class Foo {", " return 0", "}" }; - var result = ILOutputService.FilterIlLines(lines, false, new List()); + var result = ILOutputService.FilterIlLines(lines, false, new List(), Array.Empty()); Assert.Equal(lines, result); } @@ -639,40 +1155,24 @@ public void StreamingFilteredSequenceEqual_MatchesSplitAndFilterBehavior() Assert.NotNull(splitAndFilter); var ignoreStrings = new List(); - var legacy1 = (List)splitAndFilter.Invoke(null, new object[] { ilText1, false, ignoreStrings, true })!; - var legacy2 = (List)splitAndFilter.Invoke(null, new object[] { ilText2, false, ignoreStrings, true })!; + var legacy1 = (List)splitAndFilter.Invoke(null, new object[] { ilText1, false, ignoreStrings, Array.Empty() })!; + var legacy2 = (List)splitAndFilter.Invoke(null, new object[] { ilText2, false, ignoreStrings, Array.Empty() })!; bool legacyResult = legacy1.SequenceEqual(legacy2); var lines1 = DotNetDisassembleService.SplitToLines(ilText1); var lines2 = DotNetDisassembleService.SplitToLines(ilText2); - bool streamingResult = ILOutputService.StreamingFilteredSequenceEqual(lines1, lines2, false, ignoreStrings); + bool streamingResult = ILOutputService.StreamingFilteredSequenceEqual(lines1, lines2, false, ignoreStrings, Array.Empty()); Assert.Equal(legacyResult, streamingResult); } - // --- ShouldIgnoreMVID tests / MVID 無視フラグテスト --- - - [Fact] - [Trait("Category", "Unit")] - public void StreamingFilteredSequenceEqual_WhenShouldIgnoreMVIDFalse_IncludesMVIDLines() - { - // When ShouldIgnoreMVID is false, MVID lines affect comparison result. - // ShouldIgnoreMVID が false の場合、MVID 行は比較結果に影響する。 - var lines1 = new List { "// MVID: ABC", "class Foo {", "}" }; - var lines2 = new List { "// MVID: XYZ", "class Foo {", "}" }; - var result = ILOutputService.StreamingFilteredSequenceEqual(lines1, lines2, false, new List(), shouldIgnoreMVID: false); - Assert.False(result); - } - [Fact] [Trait("Category", "Unit")] - public void StreamingFilteredSequenceEqual_WhenShouldIgnoreMVIDTrue_ExcludesMVIDLines() + public void StreamingFilteredSequenceEqual_DifferentMvidValues_NormalizesMvidLines() { - // Default behavior: MVID lines are excluded from comparison. - // デフォルト動作: MVID 行は比較から除外される。 var lines1 = new List { "// MVID: ABC", "class Foo {", "}" }; var lines2 = new List { "// MVID: XYZ", "class Foo {", "}" }; - var result = ILOutputService.StreamingFilteredSequenceEqual(lines1, lines2, false, new List(), shouldIgnoreMVID: true); + var result = ILOutputService.StreamingFilteredSequenceEqual(lines1, lines2, false, new List(), Array.Empty()); Assert.True(result); } @@ -686,7 +1186,7 @@ public void StreamingFilteredSequenceEqual_WhitespaceDifferences_ReturnsTrue() // 先頭・末尾空白のみ異なる行は等価として扱われるべき。 var lines1 = new List { " .method public void Foo()", " ldarg.0", " ret" }; var lines2 = new List { ".method public void Foo()", " ldarg.0", " ret" }; - var result = ILOutputService.StreamingFilteredSequenceEqual(lines1, lines2, false, new List()); + var result = ILOutputService.StreamingFilteredSequenceEqual(lines1, lines2, false, new List(), Array.Empty()); Assert.True(result); } @@ -698,7 +1198,7 @@ public void StreamingFilteredSequenceEqual_ContentDiffBeyondWhitespace_ReturnsFa // 実際の内容が異なる行(空白だけでなく)は引き続き検出されるべき。 var lines1 = new List { " call void Foo()" }; var lines2 = new List { " call void Bar()" }; - var result = ILOutputService.StreamingFilteredSequenceEqual(lines1, lines2, false, new List()); + var result = ILOutputService.StreamingFilteredSequenceEqual(lines1, lines2, false, new List(), Array.Empty()); Assert.False(result); } diff --git a/FolderDiffIL4DotNet.Tests/Services/ReportGenerateServiceTests.Header.cs b/FolderDiffIL4DotNet.Tests/Services/ReportGenerateServiceTests.Header.cs index 6347d7c3..46286b1f 100644 --- a/FolderDiffIL4DotNet.Tests/Services/ReportGenerateServiceTests.Header.cs +++ b/FolderDiffIL4DotNet.Tests/Services/ReportGenerateServiceTests.Header.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using FolderDiffIL4DotNet.Common; using FolderDiffIL4DotNet.Models; using FolderDiffIL4DotNet.Services; @@ -109,7 +110,7 @@ public void GenerateDiffReport_HeaderOmitsAvailabilityTable_WhenProbeResultsAreN } [Fact] - public void GenerateDiffReport_HeaderShowsMvidReasonNote() + public void GenerateDiffReport_HeaderShowsBuiltInNormalizationRules() { var oldDir = Path.Combine(_rootDir, "old-mvid-note"); var newDir = Path.Combine(_rootDir, "new-mvid-note"); @@ -123,26 +124,24 @@ public void GenerateDiffReport_HeaderShowsMvidReasonNote() var reportPath = Path.Combine(reportDir, "diff_report.md"); var reportText = File.ReadAllText(reportPath); - Assert.Contains("lines starting with \"// MVID:\" (if present) are ignored because they contain disassembler-emitted Module Version ID metadata", reportText); - } - - [Fact] - public void GenerateDiffReport_WhenShouldIgnoreMVIDFalse_OmitsMvidNote() - { - var oldDir = Path.Combine(_rootDir, "old-mvid-off"); - var newDir = Path.Combine(_rootDir, "new-mvid-off"); - var reportDir = Path.Combine(_rootDir, "report-mvid-off"); - Directory.CreateDirectory(oldDir); - Directory.CreateDirectory(newDir); - Directory.CreateDirectory(reportDir); - - var builder = CreateConfigBuilder(); - builder.ShouldIgnoreMVID = false; - _service.GenerateDiffReport(CreateReportContext(oldDir, newDir, reportDir, builder.Build())); - - var reportPath = Path.Combine(reportDir, "diff_report.md"); - var reportText = File.ReadAllText(reportPath); - Assert.DoesNotContain("// MVID:", reportText); + Assert.Contains("**Built-in IL Normalization**", reportText); + Assert.Contains( + "Rules apply in the listed order to all IL text, preserving each matching prefix and replacing only its build-variant value.", + reportText); + Assert.Contains("| Line Prefix Pattern | Replacement | Observed Output From |", reportText); + Assert.DoesNotContain("All listed rules are applied to every IL text", reportText); + Assert.DoesNotContain("For ilspycmd's multiline TypeLibraryTimeStampAttribute", reportText); + Assert.DoesNotContain("Disassembler (Observed In)", reportText); + Assert.DoesNotContain("it does not limit where the rule is applied", reportText); + Assert.Contains("`// MVID:` | `` | `dotnet-ildasm`", reportText); + Assert.Contains("`// Method begins at RVA 0x` | `` | `ilspycmd`", reportText); + Assert.Contains("`// Code size: ` | `` | `ilspycmd`", reportText); + Assert.Contains($"`{Constants.IL_ILSPY_TYPE_LIBRARY_TIMESTAMP_LINE_PREFIX}` | `` | `ilspycmd`", reportText); + var expectedRuleOrder = ILOutputService.BuiltInNormalizationRules + .OrderBy(rule => rule.Prefix, StringComparer.Ordinal) + .ThenBy(rule => rule.Disassembler, StringComparer.Ordinal) + .ToArray(); + Assert.Equal(expectedRuleOrder, ILOutputService.BuiltInNormalizationRules); } [Fact] @@ -193,9 +192,10 @@ public void GenerateDiffReport_HeaderShowsIlContainsIgnoreNote_WhenEnabled() var reportPath = Path.Combine(reportDir, "diff_report.md"); var reportText = File.ReadAllText(reportPath); Assert.Contains("lines containing any of the configured strings are ignored:", reportText); - Assert.Contains("| Ignored String |", reportText); - Assert.Contains("| \"buildserver\" |", reportText); - Assert.Contains("| \"buildPath\" |", reportText); + Assert.Contains("| Substring to Ignore (Escaped) |", reportText); + Assert.Contains("| buildserver |", reportText); + Assert.Contains("| buildPath |", reportText); + Assert.DoesNotContain("| \"buildserver\" |", reportText); } [Fact] @@ -221,7 +221,7 @@ public void GenerateDiffReport_HeaderOmitsIlContainsIgnoreNote_WhenDisabled() } [Fact] - public void GenerateDiffReport_HeaderShowsEmptyIlContainsIgnoreNote_WhenEnabledButNoValidStrings() + public void GenerateDiffReport_HeaderShowsIlContainsIgnoreNote_WhenEnabledButNoValidStrings() { var oldDir = Path.Combine(_rootDir, "old-ignore-note-empty"); var newDir = Path.Combine(_rootDir, "new-ignore-note-empty"); @@ -239,7 +239,146 @@ public void GenerateDiffReport_HeaderShowsEmptyIlContainsIgnoreNote_WhenEnabledB var reportPath = Path.Combine(reportDir, "diff_report.md"); var reportText = File.ReadAllText(reportPath); + Assert.Contains("ILIgnoreLineContainingStrings", reportText); Assert.Contains("Enabled, but no non-empty strings are configured.", reportText); + Assert.DoesNotContain("Substring to Ignore", reportText); + } + + [Fact] + public void GenerateDiffReport_HeaderShowsNormalizationThenConfiguredNormalizationThenIgnoreStrings() + { + var oldDir = Path.Combine(_rootDir, "old-normalize-note"); + var newDir = Path.Combine(_rootDir, "new-normalize-note"); + var reportDir = Path.Combine(_rootDir, "report-normalize-note"); + Directory.CreateDirectory(oldDir); + Directory.CreateDirectory(newDir); + Directory.CreateDirectory(reportDir); + + var builder = CreateConfigBuilder(); + builder.ShouldIgnoreILLinesContainingConfiguredStrings = true; + builder.ILIgnoreLineContainingStrings = new List { "ignored-value" }; + builder.ShouldILNormalizeContainingConfiguredStrings = true; + builder.ILNormalizeContainingStrings = new List { "buildserver2_", "", "buildserver1_", "buildserver2_" }; + + _service.GenerateDiffReport(CreateReportContext(oldDir, newDir, reportDir, builder.Build())); + + var reportText = File.ReadAllText(Path.Combine(reportDir, "diff_report.md")); + int builtInIndex = reportText.IndexOf("**Built-in IL Normalization**", StringComparison.Ordinal); + int ignoreIndex = reportText.IndexOf("**ILIgnoreLineContainingStrings**", StringComparison.Ordinal); + int normalizeIndex = reportText.IndexOf("**ILNormalizeContainingStrings**", StringComparison.Ordinal); + Assert.True(builtInIndex >= 0); + Assert.True(normalizeIndex > builtInIndex); + Assert.True(ignoreIndex > normalizeIndex); + Assert.Contains( + "Matches are replaced in the listed order with a comparison-local marker absent from both inputs; all other text remains comparable.", + reportText); + Assert.DoesNotContain("Every occurrence of each configured substring", reportText); + Assert.DoesNotContain("comparison-local collision-free marker", reportText); + Assert.DoesNotContain("", reportText); + Assert.DoesNotContain("all remaining text on the line stays unchanged and comparable", reportText); + Assert.DoesNotContain("The substrings are listed in application order.", reportText); + Assert.Equal(1, reportText.Split("| buildserver1_ |", StringSplitOptions.None).Length - 1); + Assert.Equal(1, reportText.Split("| buildserver2_ |", StringSplitOptions.None).Length - 1); + Assert.True( + reportText.IndexOf("| buildserver2_ |", StringComparison.Ordinal) + < reportText.IndexOf("| buildserver1_ |", StringComparison.Ordinal)); + Assert.DoesNotContain("| \"buildserver1_\" |", reportText); + } + + [Fact] + public void GenerateDiffReport_HeaderShowsNormalizeStrings_WhenEnabledButNoValidValues() + { + var oldDir = Path.Combine(_rootDir, "old-normalize-empty"); + var newDir = Path.Combine(_rootDir, "new-normalize-empty"); + var reportDir = Path.Combine(_rootDir, "report-normalize-empty"); + Directory.CreateDirectory(oldDir); + Directory.CreateDirectory(newDir); + Directory.CreateDirectory(reportDir); + + var builder = CreateConfigBuilder(); + builder.ShouldILNormalizeContainingConfiguredStrings = true; + builder.ILNormalizeContainingStrings = new List { "", " " }; + + _service.GenerateDiffReport(CreateReportContext(oldDir, newDir, reportDir, builder.Build())); + + var reportText = File.ReadAllText(Path.Combine(reportDir, "diff_report.md")); + Assert.Contains("ILNormalizeContainingStrings", reportText); + Assert.Contains("Enabled, but no non-empty strings are configured.", reportText); + Assert.DoesNotContain("Substring to Normalize", reportText); + } + + [Fact] + public void GenerateDiffReport_HeaderUsesReversibleEscapesForConfiguredStringsInMarkdownTables() + { + var oldDir = Path.Combine(_rootDir, "old-configured-markdown"); + var newDir = Path.Combine(_rootDir, "new-configured-markdown"); + var reportDir = Path.Combine(_rootDir, "report-configured-markdown"); + Directory.CreateDirectory(oldDir); + Directory.CreateDirectory(newDir); + Directory.CreateDirectory(reportDir); + + var builder = CreateConfigBuilder(); + builder.ShouldILNormalizeContainingConfiguredStrings = true; + builder.ILNormalizeContainingStrings = new List + { + "normalize|value", + "line1\r\nline2", + " padded ", + "\ttabbed\t", + "inner\tspace value\nnext\u00A0", + @"\temp\develop\", + @"\\temp\\develop\\", + "`code` *emphasis* _underscore_ ~~strike~~", + "[link](https://example.com)", + "![image](https://example.com/image.png)", + "format\u200Dcontrol\0", + "emoji\U0001F600value" + }; + builder.ShouldIgnoreILLinesContainingConfiguredStrings = true; + builder.ILIgnoreLineContainingStrings = new List { "ignore|value" }; + + _service.GenerateDiffReport(CreateReportContext(oldDir, newDir, reportDir, builder.Build())); + + var reportText = File.ReadAllText(Path.Combine(reportDir, "diff_report.md")); + Assert.Contains("| Substring to Normalize (Escaped) |", reportText); + Assert.Contains("| normalize|value |", reportText); + Assert.Contains("| line1\r\nline2 |", reportText); + Assert.Contains("| \u0020\u0020padded\u0020\u0020 |", reportText); + Assert.Contains("| \ttabbed\t |", reportText); + Assert.Contains("| inner\tspace\u0020value\nnext\u00A0 |", reportText); + Assert.Contains("| \\temp\\develop\\ |", reportText); + Assert.Contains("| \\\\temp\\\\develop\\\\ |", reportText); + Assert.Contains("| `code`\u0020*emphasis*\u0020_underscore_\u0020~~strike~~ |", reportText); + Assert.Contains("| [link](https://example.com) |", reportText); + Assert.Contains("| ![image](https://example.com/image.png) |", reportText); + Assert.Contains("| format\u200Dcontrol\u0000 |", reportText); + Assert.Contains("| emoji😀value |", reportText); + Assert.Contains("| ignore|value |", reportText); + Assert.DoesNotContain("
", reportText); + Assert.DoesNotContain(" ", reportText); + Assert.DoesNotContain("*emphasis*", reportText); + Assert.DoesNotContain("[link](https://example.com)", reportText); + Assert.DoesNotContain("![image](https://example.com/image.png)", reportText); + } + + [Fact] + public void GenerateDiffReport_HeaderOmitsNormalizeStrings_WhenDisabledWithValues() + { + var oldDir = Path.Combine(_rootDir, "old-normalize-disabled"); + var newDir = Path.Combine(_rootDir, "new-normalize-disabled"); + var reportDir = Path.Combine(_rootDir, "report-normalize-disabled"); + Directory.CreateDirectory(oldDir); + Directory.CreateDirectory(newDir); + Directory.CreateDirectory(reportDir); + + var builder = CreateConfigBuilder(); + builder.ShouldILNormalizeContainingConfiguredStrings = false; + builder.ILNormalizeContainingStrings = new List { "buildserver1_" }; + + _service.GenerateDiffReport(CreateReportContext(oldDir, newDir, reportDir, builder.Build())); + + var reportText = File.ReadAllText(Path.Combine(reportDir, "diff_report.md")); + Assert.DoesNotContain("ILNormalizeContainingStrings", reportText); } // ----------------------------------------------------------------------- @@ -334,4 +473,3 @@ public void GenerateDiffReport_NoMixedWarning_WhenSingleDisassemblerUsed() } } } - diff --git a/FolderDiffIL4DotNet.Tests/Services/SectionWriters/ConditionalSectionWriterTests.cs b/FolderDiffIL4DotNet.Tests/Services/SectionWriters/ConditionalSectionWriterTests.cs index 06b9baac..c82c2a71 100644 --- a/FolderDiffIL4DotNet.Tests/Services/SectionWriters/ConditionalSectionWriterTests.cs +++ b/FolderDiffIL4DotNet.Tests/Services/SectionWriters/ConditionalSectionWriterTests.cs @@ -134,13 +134,14 @@ public void Warnings_IsEnabled_WhenChecklistItemsExist() } [Fact] - public void Warnings_Write_ContainsILFilterWarningText() + public void Warnings_Write_ContainsConfiguredIlSubstringSafetyWarningText() { var writer = SectionWriterTestBase.GetWriterByOrder(1000); var ctx = SectionWriterTestBase.CreateMinimalContext(hasILFilterWarnings: true); string output = SectionWriterTestBase.WriteToString(writer, ctx); - Assert.Contains("IL filter validation warnings", output); - Assert.Contains("very short", output); + Assert.Contains("IL substring configuration safety warnings", output); + Assert.Contains("ILNormalizeContainingStrings", output); + Assert.Contains("overlap by containment", output); } } } diff --git a/FolderDiffIL4DotNet.Tests/Services/SectionWriters/SectionWriterTestBase.cs b/FolderDiffIL4DotNet.Tests/Services/SectionWriters/SectionWriterTestBase.cs index 85cf4705..ba449a09 100644 --- a/FolderDiffIL4DotNet.Tests/Services/SectionWriters/SectionWriterTestBase.cs +++ b/FolderDiffIL4DotNet.Tests/Services/SectionWriters/SectionWriterTestBase.cs @@ -47,7 +47,7 @@ internal static ReportWriteContext CreateMinimalContext( var fileDiffResultLists = new FileDiffResultLists(); if (hasILFilterWarnings) { - fileDiffResultLists.ILFilterWarnings.Add("ILIgnoreLineContainingStrings: \"ab\" is very short (2 chars) and may inadvertently exclude legitimate IL lines. Consider using a more specific pattern."); + fileDiffResultLists.ILFilterWarnings.Add("ILNormalizeContainingStrings: configured values overlap by containment."); } return new ReportWriteContext diff --git a/FolderDiffIL4DotNet.Tests/Services/SectionWriters/WarningsSectionWriterTests.cs b/FolderDiffIL4DotNet.Tests/Services/SectionWriters/WarningsSectionWriterTests.cs index ee706e93..b33630bf 100644 --- a/FolderDiffIL4DotNet.Tests/Services/SectionWriters/WarningsSectionWriterTests.cs +++ b/FolderDiffIL4DotNet.Tests/Services/SectionWriters/WarningsSectionWriterTests.cs @@ -62,12 +62,13 @@ public void IsEnabled_WhenChecklistItemsExist() } [Fact] - public void Write_WithILFilterWarnings_ContainsWarningText() + public void Write_WithILFilterWarnings_ContainsGenericSafetyHeading() { var writer = SectionWriterTestBase.GetWriterByOrder(1000); var ctx = SectionWriterTestBase.CreateMinimalContext(hasILFilterWarnings: true); string output = SectionWriterTestBase.WriteToString(writer, ctx); - Assert.Contains("IL filter validation warnings", output); + Assert.Contains("IL substring configuration safety warnings", output); + Assert.DoesNotContain("IL filter validation warnings", output); } [Fact] @@ -76,7 +77,25 @@ public void Write_WithILFilterWarnings_ContainsWarningMessage() var writer = SectionWriterTestBase.GetWriterByOrder(1000); var ctx = SectionWriterTestBase.CreateMinimalContext(hasILFilterWarnings: true); string output = SectionWriterTestBase.WriteToString(writer, ctx); - Assert.Contains("ILIgnoreLineContainingStrings", output); + Assert.Contains("ILNormalizeContainingStrings", output); + Assert.Contains("overlap by containment", output); + } + + [Fact] + public void Write_WithValidatedConfiguredNormalizationWarning_PreservesSafeEscapes() + { + var writer = SectionWriterTestBase.GetWriterByOrder(1000); + var ctx = SectionWriterTestBase.CreateMinimalContext(hasILFilterWarnings: true); + string warning = Assert.Single(ILOutputService.ValidateILNormalizeContainingStrings( + new[] { "buildserver1_", "buildserver1_artifact" })); + ctx.FileDiffResultLists.ILFilterWarnings.Clear(); + ctx.FileDiffResultLists.ILFilterWarnings.Add(warning); + + string output = SectionWriterTestBase.WriteToString(writer, ctx); + + Assert.Contains("buildserver1_", output); + Assert.Contains("buildserver1_artifact", output); + Assert.DoesNotContain("buildserver1_", output); } [Fact] diff --git a/Models/ConfigSettings.ILSettings.cs b/Models/ConfigSettings.ILSettings.cs index 6c24cda6..bc040865 100644 --- a/Models/ConfigSettings.ILSettings.cs +++ b/Models/ConfigSettings.ILSettings.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Text; namespace FolderDiffIL4DotNet.Models { @@ -11,10 +12,20 @@ public sealed partial class ConfigSettings public const bool DefaultShouldOutputILText = true; /// Default value for . / の既定値。 public const bool DefaultShouldIgnoreILLinesContainingConfiguredStrings = false; + /// Default value for . / の既定値。 + public const bool DefaultShouldILNormalizeContainingConfiguredStrings = false; /// Default value for . / の既定値。 public const bool DefaultSkipIL = false; - /// Default value for . / の既定値。 - public const bool DefaultShouldIgnoreMVID = true; + /// + /// Maximum number of entries after creator-profile values are prepended. + /// creator プロファイル値を先頭へ追加した後の の最大件数。 + /// + public const int MaxILNormalizeContainingStringsCount = 256; + /// + /// Maximum number of Unicode characters in one value. + /// 1 値に含められる Unicode 文字の最大数。 + /// + public const int MaxILNormalizeContainingStringLength = 4096; // ── IL cache defaults / IL キャッシュデフォルト ─────────────────────── /// Default value for . / の既定値。 @@ -57,19 +68,67 @@ public sealed partial class ConfigSettings public IReadOnlyList ILIgnoreLineContainingStrings { get; } /// - /// Whether to skip IL comparison for .NET assemblies. - /// .NET アセンブリの IL 比較をスキップするかどうか。 + /// Whether to normalize configured matching portions of IL lines during comparison. + /// IL 比較時に設定文字列と一致する部分を正規化するかどうか。 /// - public bool SkipIL { get; } + public bool ShouldILNormalizeContainingConfiguredStrings { get; } /// - /// Whether to ignore MVID (Module Version ID) lines during IL comparison. - /// When false, MVID differences are included in the comparison, which can detect - /// recompilation even when the source code is identical. - /// IL 比較時に MVID(Module Version ID)行を無視するかどうか。 - /// false の場合、ソースコードが同一でも再コンパイルを検出するために MVID 差異が比較に含まれる。 + /// List of strings whose exact matching portions are normalized during IL comparison; surrounding whitespace is significant. + /// At most combined configured/profile entries are allowed, each no longer than + /// Unicode characters. + /// IL 比較時に正確な一致部分を正規化する文字列リスト。前後空白も一致条件に含みます。 + /// 設定値とプロファイル値の結合後は最大 件で、各値は + /// Unicode 文字以下です。 /// - public bool ShouldIgnoreMVID { get; } + public IReadOnlyList ILNormalizeContainingStrings { get; } + + internal static string? GetILNormalizeContainingStringsLimitError(IReadOnlyList? configuredStrings) + { + if (configuredStrings == null) + { + return null; + } + + if (configuredStrings.Count > MaxILNormalizeContainingStringsCount) + { + return $"{nameof(ILNormalizeContainingStrings)} must contain at most {MaxILNormalizeContainingStringsCount} values (current count: {configuredStrings.Count}). Creator-profile values prepended before configured values count toward this limit."; + } + + for (int index = 0; index < configuredStrings.Count; index++) + { + string? value = configuredStrings[index]; + if (value != null && ExceedsILNormalizeContainingStringLengthLimit(value)) + { + return $"{nameof(ILNormalizeContainingStrings)}[{index}] must be {MaxILNormalizeContainingStringLength} Unicode characters or fewer."; + } + } + + return null; + } + + private static bool ExceedsILNormalizeContainingStringLengthLimit(string value) + { + // Match JSON Schema maxLength's Unicode code-point semantics and stop after the first excess value. + // JSON Schema maxLength の Unicode コードポイント基準に合わせ、上限超過を確認した時点で走査を止めます。 + int unicodeCharacterCount = 0; + foreach (Rune _ in value.EnumerateRunes()) + { + unicodeCharacterCount++; + if (unicodeCharacterCount > MaxILNormalizeContainingStringLength) + { + return true; + } + } + + return false; + } + + /// + /// Whether to skip IL comparison for .NET assemblies. + /// .NET アセンブリの IL 比較をスキップするかどうか。 + /// + public bool SkipIL { get; } // ── IL cache properties / IL キャッシュプロパティ ───────────────────── diff --git a/Models/ConfigSettings.cs b/Models/ConfigSettings.cs index cdfb9369..f65e262d 100644 --- a/Models/ConfigSettings.cs +++ b/Models/ConfigSettings.cs @@ -102,9 +102,9 @@ internal ConfigSettings(ConfigSettingsBuilder builder) ShouldOutputILText = builder.ShouldOutputILText; ShouldIgnoreILLinesContainingConfiguredStrings = builder.ShouldIgnoreILLinesContainingConfiguredStrings; ILIgnoreLineContainingStrings = builder.ILIgnoreLineContainingStrings.AsReadOnly(); + ShouldILNormalizeContainingConfiguredStrings = builder.ShouldILNormalizeContainingConfiguredStrings; + ILNormalizeContainingStrings = builder.ILNormalizeContainingStrings.AsReadOnly(); SkipIL = builder.SkipIL; - ShouldIgnoreMVID = builder.ShouldIgnoreMVID; - // IL cache / IL キャッシュ EnableILCache = builder.EnableILCache; ILCacheDirectoryAbsolutePath = builder.ILCacheDirectoryAbsolutePath; diff --git a/Models/ConfigSettingsBuilder.ILSettings.cs b/Models/ConfigSettingsBuilder.ILSettings.cs index d83a5a06..fd625a44 100644 --- a/Models/ConfigSettingsBuilder.ILSettings.cs +++ b/Models/ConfigSettingsBuilder.ILSettings.cs @@ -7,6 +7,7 @@ namespace FolderDiffIL4DotNet.Models public sealed partial class ConfigSettingsBuilder { private List _ilIgnoreLineContainingStrings = new(); + private List _ilNormalizeContainingStrings = new(); private string _ilCacheDirectoryAbsolutePath = string.Empty; // ── IL comparison / IL 比較 ───────────────────────────────────────── @@ -24,12 +25,19 @@ public List ILIgnoreLineContainingStrings set => _ilIgnoreLineContainingStrings = value ?? new List(); } + /// + public bool ShouldILNormalizeContainingConfiguredStrings { get; set; } = ConfigSettings.DefaultShouldILNormalizeContainingConfiguredStrings; + + /// + public List ILNormalizeContainingStrings + { + get => _ilNormalizeContainingStrings; + set => _ilNormalizeContainingStrings = value ?? new List(); + } + /// public bool SkipIL { get; set; } = ConfigSettings.DefaultSkipIL; - /// - public bool ShouldIgnoreMVID { get; set; } = ConfigSettings.DefaultShouldIgnoreMVID; - // ── IL cache / IL キャッシュ ──────────────────────────────────────── /// diff --git a/Models/ConfigSettingsBuilder.cs b/Models/ConfigSettingsBuilder.cs index c2cba477..8865917c 100644 --- a/Models/ConfigSettingsBuilder.cs +++ b/Models/ConfigSettingsBuilder.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Text.Json; using System.Text.Json.Serialization; @@ -21,6 +22,9 @@ namespace FolderDiffIL4DotNet.Models ///
public sealed partial class ConfigSettingsBuilder { + internal const string REMOVED_SHOULD_IGNORE_MVID_ERROR = + "The 'ShouldIgnoreMVID' setting has been removed. MVID values are always normalized; remove this setting from the configuration."; + /// /// Captures unmapped JSON properties (e.g. $schema) so that deserialization /// does not fail when the config file contains a schema reference. @@ -30,6 +34,27 @@ public sealed partial class ConfigSettingsBuilder [JsonExtensionData] public Dictionary? ExtensionData { get; set; } + internal bool HasRemovedShouldIgnoreMvidSetting + { + get + { + if (ExtensionData == null) + { + return false; + } + + foreach (string propertyName in ExtensionData.Keys) + { + if (string.Equals(propertyName, "ShouldIgnoreMVID", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + } + private List _ignoredExtensions = ConfigSettings.CreateDefaultIgnoredExtensions(); private List _textFileExtensions = ConfigSettings.CreateDefaultTextFileExtensions(); private List _spinnerFrames = ConfigSettings.CreateDefaultSpinnerFrames(); @@ -70,6 +95,11 @@ public ConfigValidationResult Validate() { var errors = new List(); + if (HasRemovedShouldIgnoreMvidSetting) + { + errors.Add(REMOVED_SHOULD_IGNORE_MVID_ERROR); + } + if (MaxLogGenerations < 1) { errors.Add($"MaxLogGenerations must be 1 or greater (current value: {MaxLogGenerations})."); @@ -104,6 +134,12 @@ public ConfigValidationResult Validate() errors.Add($"ILCacheMaxMemoryMegabytes must be 0 or greater (current value: {ILCacheMaxMemoryMegabytes})."); } + string? ilNormalizationLimitError = ConfigSettings.GetILNormalizeContainingStringsLimitError(ILNormalizeContainingStrings); + if (ilNormalizationLimitError != null) + { + errors.Add(ilNormalizationLimitError); + } + return new ConfigValidationResult(errors); } diff --git a/Models/FileDiffResultLists.Metadata.cs b/Models/FileDiffResultLists.Metadata.cs index b1689db0..9c35a07f 100644 --- a/Models/FileDiffResultLists.Metadata.cs +++ b/Models/FileDiffResultLists.Metadata.cs @@ -93,22 +93,24 @@ public void RecordNewFileTimestampOlderThanOldWarning(string fileRelativePath, s } // ────────────────────────────────────────────── - // IL filter warnings - // IL フィルタ警告 + // Configured IL substring safety warnings + // 設定された IL 部分文字列の安全性警告 // ────────────────────────────────────────────── /// - /// Warning messages for potentially over-broad IL filter strings configured via - /// ILIgnoreLineContainingStrings. Recorded during diff execution and - /// rendered in both Markdown and HTML report Warnings sections. - /// ILIgnoreLineContainingStrings で設定された過度に広範な IL フィルタ文字列に対する - /// 警告メッセージ。差分実行時に記録され、Markdown および HTML レポートの警告セクションに表示されます。 + /// Safety warning messages for configured IL substrings from + /// ILIgnoreLineContainingStrings and ILNormalizeContainingStrings, + /// including short values, duplicates, and containment relationships. Recorded during + /// diff execution and rendered in both Markdown and HTML report Warnings sections. + /// ILIgnoreLineContainingStrings および ILNormalizeContainingStrings で + /// 設定された IL 部分文字列に対する安全性警告メッセージ。短い値、重複、包含関係を含みます。 + /// 差分実行時に記録され、Markdown および HTML レポートの警告セクションに表示されます。 /// public ConcurrentBag ILFilterWarnings { get; } = new ConcurrentBag(); /// - /// Returns when at least one IL filter warning exists. - /// IL フィルタ警告が 1 件以上ある場合に を返します。 + /// Returns when at least one configured IL substring safety warning exists. + /// 設定された IL 部分文字列の安全性警告が 1 件以上ある場合に を返します。 /// public bool HasAnyILFilterWarning => !ILFilterWarnings.IsEmpty; diff --git a/Models/IReadOnlyConfigSettings.cs b/Models/IReadOnlyConfigSettings.cs index f8705f78..eb204637 100644 --- a/Models/IReadOnlyConfigSettings.cs +++ b/Models/IReadOnlyConfigSettings.cs @@ -82,12 +82,22 @@ public interface IReadOnlyConfigSettings /// List of strings to ignore in IL lines during comparison. / IL 比較時に無視対象とする文字列リスト。 IReadOnlyList ILIgnoreLineContainingStrings { get; } + /// Whether to normalize configured matching portions of IL lines. / IL 行の設定文字列一致部分を正規化するかどうか。 + bool ShouldILNormalizeContainingConfiguredStrings { get; } + + /// + /// List of strings whose exact matching portions are normalized during IL comparison; surrounding whitespace is significant. + /// At most combined configured/profile entries of up to + /// Unicode characters each are allowed. + /// IL 比較時に正確な一致部分を正規化する文字列リスト。前後空白も一致条件に含みます。 + /// 設定値とプロファイル値の結合後は最大 件、 + /// 各値は最大 Unicode 文字です。 + /// + IReadOnlyList ILNormalizeContainingStrings { get; } + /// Whether to skip IL comparison for .NET assemblies. / .NET アセンブリの IL 比較をスキップするかどうか。 bool SkipIL { get; } - /// Whether to ignore MVID lines during IL comparison (default true). / IL 比較時に MVID 行を無視するかどうか(デフォルト true)。 - bool ShouldIgnoreMVID { get; } - // ── IL cache / IL キャッシュ ──────────────────────────────────────── /// Whether to cache IL disassembly results. / IL 逆アセンブル結果をキャッシュするか。 diff --git a/PACKAGE_README.md b/PACKAGE_README.md index b45a70c9..2abe2b59 100644 --- a/PACKAGE_README.md +++ b/PACKAGE_README.md @@ -54,7 +54,7 @@ nildiff "/old" "/new" "label" --config /path/to/config.json When the report label is omitted, nildiff auto-generates a high-resolution timestamp label. Tokens beginning with `--` are treated as options, not report labels. -Individual settings can also be overridden via `FOLDERDIFF_*` environment variables (e.g. `FOLDERDIFF_MAXPARALLELISM=8`). For maintainer-only IL noise suppression, `--creator` applies the predefined `buildserver-winforms` `ILIgnoreLineContainingStrings` profile. See the [annotated sample config](https://github.com/Widthdom/FolderDiffIL4DotNet/blob/main/doc/config.sample.jsonc) for all available settings. +Individual settings can also be overridden via `FOLDERDIFF_*` environment variables (e.g. `FOLDERDIFF_MAXPARALLELISM=8`). For maintainer-only IL noise normalization, `--creator` enables `ShouldILNormalizeContainingConfiguredStrings` and merges the predefined `creator-default` values into `ILNormalizeContainingStrings`. See the [annotated sample config](https://github.com/Widthdom/FolderDiffIL4DotNet/blob/main/doc/config.sample.jsonc) for all available settings. The default user-local `config.json` location varies by OS: diff --git a/ProgramRunner.cs b/ProgramRunner.cs index bdd8bf5c..58b360b3 100644 --- a/ProgramRunner.cs +++ b/ProgramRunner.cs @@ -26,6 +26,16 @@ public sealed partial class ProgramRunner public async Task RunAsync(string[] args) { var opts = CliParser.Parse(args); + bool hasInformationalEarlyExit = opts.ShowHelp + || opts.ShowVersion + || opts.ShowBanner + || opts.ShowCredits; + if (opts.ParseError != null && !hasInformationalEarlyExit) + { + Console.Error.WriteLine(opts.ParseError); + return (int)ProgramExitCode.InvalidArguments; + } + bool updateCompleted = await _updateNotificationService.TryNotifyAsync( SystemInfo.GetAppVersion(typeof(Program)), Console.Error, @@ -299,7 +309,7 @@ private void OutputCompletionWarnings(bool hasSha256MismatchWarnings, bool hasTi if (hasILFilterWarnings) { - _logger.LogMessage(AppLogLevel.Warning, WARNING_IL_FILTER_STRINGS_TOO_SHORT, shouldOutputMessageToConsole: true, ConsoleColor.Yellow); + _logger.LogMessage(AppLogLevel.Warning, WARNING_IL_SUBSTRING_CONFIGURATION_SAFETY, shouldOutputMessageToConsole: true, ConsoleColor.Yellow); } } diff --git a/Runner/CliOverrideApplier.cs b/Runner/CliOverrideApplier.cs index e60fc1f8..8fe23477 100644 --- a/Runner/CliOverrideApplier.cs +++ b/Runner/CliOverrideApplier.cs @@ -45,18 +45,17 @@ internal static void Apply(ConfigSettingsBuilder builder, CliOptions opts) if (creatorProfile != null) { - builder.ShouldIgnoreILLinesContainingConfiguredStrings = true; - var mergedStrings = new List(builder.ILIgnoreLineContainingStrings); - var seen = new HashSet(mergedStrings, System.StringComparer.Ordinal); - foreach (var value in CreatorPrivilegeIlIgnoreProfiles.GetStringsOrThrow(creatorProfile)) - { - if (seen.Add(value)) - { - mergedStrings.Add(value); - } - } + builder.ShouldILNormalizeContainingConfiguredStrings = true; + var configuredStrings = builder.ILNormalizeContainingStrings; + var orderedStrings = new List( + CreatorPrivilegeIlIgnoreProfiles.GetStringsOrThrow(creatorProfile)); - builder.ILIgnoreLineContainingStrings = mergedStrings; + // Creator defaults form the normalization baseline. Keep configured values after + // them, preserving duplicates so validation can report cross-source relationships. + // creator 既定値を正規化の基盤として先に置き、設定値をその後へ追加します。 + // 重複は保持し、双方にまたがる重複・包含関係を検証できるようにします。 + orderedStrings.AddRange(configuredStrings); + builder.ILNormalizeContainingStrings = orderedStrings; } SpinnerThemes.Apply(builder, opts); diff --git a/Runner/CreatorPrivilegeIlIgnoreProfiles.cs b/Runner/CreatorPrivilegeIlIgnoreProfiles.cs index fbef7e15..1335266c 100644 --- a/Runner/CreatorPrivilegeIlIgnoreProfiles.cs +++ b/Runner/CreatorPrivilegeIlIgnoreProfiles.cs @@ -7,12 +7,12 @@ namespace FolderDiffIL4DotNet.Runner { /// - /// Loads maintainer-managed IL ignore profiles from an embedded JSON catalog. - /// メンテナー管理の IL 無視プロファイルを埋め込み JSON カタログから読み込みます。 + /// Loads maintainer-managed IL normalization profiles from an embedded JSON catalog. + /// メンテナー管理の IL 正規化プロファイルを埋め込み JSON カタログから読み込みます。 /// internal static class CreatorPrivilegeIlIgnoreProfiles { - internal const string DefaultProfileName = "buildserver-winforms"; + internal const string DefaultProfileName = "creator-default"; private const string RESOURCE_NAME = "FolderDiffIL4DotNet.Runner.creator_il_ignore_profiles.json"; private static readonly Lazy>> _profiles = new(LoadProfiles); @@ -27,8 +27,8 @@ internal static bool IsKnownProfile(string? profileName) } /// - /// Resolves a known profile to its IL ignore strings. - /// 既知プロファイルを IL 無視文字列一覧へ解決します。 + /// Resolves a known profile to its IL normalization strings. + /// 既知プロファイルを IL 正規化文字列一覧へ解決します。 /// internal static IReadOnlyList GetStringsOrThrow(string profileName) { diff --git a/Runner/ProgramRunner.Config.cs b/Runner/ProgramRunner.Config.cs index 4d9362fe..64f93531 100644 --- a/Runner/ProgramRunner.Config.cs +++ b/Runner/ProgramRunner.Config.cs @@ -27,18 +27,18 @@ public sealed partial class ProgramRunner /// private async Task ClearCacheAsync(string? configPath) { - if (Console.IsInputRedirected) - { - Console.Error.WriteLine("--clear-cache requires an interactive terminal (stdin must not be redirected)."); - return (int)ProgramExitCode.InvalidArguments; - } - try { // Resolve cache directory from config (if available) or use default // 設定(利用可能な場合)またはデフォルトからキャッシュディレクトリを解決 string cacheDir = await ResolveCacheDirectoryAsync(configPath); + if (Console.IsInputRedirected) + { + Console.Error.WriteLine("--clear-cache requires an interactive terminal (stdin must not be redirected)."); + return (int)ProgramExitCode.InvalidArguments; + } + if (!Directory.Exists(cacheDir)) { Console.WriteLine($"IL cache directory does not exist: {cacheDir}"); @@ -163,6 +163,12 @@ private async Task ClearCacheAsync(string? configPath) Console.WriteLine($"Cleared {deleted} IL cache file(s) from: {cacheDir}"); return 0; } + catch (InvalidDataException ex) + { + Console.Error.WriteLine( + $"Failed to resolve IL cache configuration for '{GetConfigErrorTarget(configPath, ex)}' ({ex.GetType().Name}): {ex.Message}"); + return (int)ProgramExitCode.ConfigurationError; + } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException || AppDataPaths.IsLocalApplicationDataResolutionFailure(ex)) { @@ -186,7 +192,7 @@ private async Task ResolveCacheDirectoryAsync(string? configPath) : config.ILCacheDirectoryAbsolutePath; } catch (Exception ex) when (ex is ArgumentException or NotSupportedException - or FileNotFoundException or InvalidDataException + or FileNotFoundException or IOException or UnauthorizedAccessException) { return AppDataPaths.GetDefaultIlCacheDirectoryAbsolutePath(); diff --git a/Runner/ProgramRunner.Core.cs b/Runner/ProgramRunner.Core.cs index b37f14d2..2001076b 100644 --- a/Runner/ProgramRunner.Core.cs +++ b/Runner/ProgramRunner.Core.cs @@ -20,7 +20,7 @@ public sealed partial class ProgramRunner private const string PRESS_ANY_KEY = "Press any key to exit..."; private const string ERROR_KEY_PROMPT = "An error occurred during key prompt."; private const string WARNING_NEW_FILE_TIMESTAMP_OLDER_THAN_OLD = "One or more modified files in 'new' have older timestamps than the corresponding files in 'old'. See diff_report for details."; - private const string WARNING_IL_FILTER_STRINGS_TOO_SHORT = "One or more ILIgnoreLineContainingStrings entries are very short and may inadvertently exclude legitimate IL lines. See diff_report Warnings section for details."; + private const string WARNING_IL_SUBSTRING_CONFIGURATION_SAFETY = "One or more configured IL substring safety warnings were detected. See the diff_report Warnings section for details."; private const string TIP_PRINT_CONFIG = "Tip: Run with --print-config to display the effective configuration as JSON."; private const string INFO_AUTO_GENERATED_REPORT_LABEL = "Report label was not specified. Using auto-generated label: "; diff --git a/Runner/ProgramRunner.HelpText.cs b/Runner/ProgramRunner.HelpText.cs index a1ea402d..72e09887 100644 --- a/Runner/ProgramRunner.HelpText.cs +++ b/Runner/ProgramRunner.HelpText.cs @@ -30,9 +30,9 @@ public sealed partial class ProgramRunner " --clear-cache Interactive wizard to selectively delete IL cache files.\n" + " --skip-il Skip IL comparison for .NET assemblies.\n" + " --no-timestamp-warnings Suppress timestamp-regression warnings.\n" + - " --creator Apply the default maintainer IL ignore profile.\n" + + " --creator Apply the default maintainer IL normalization profile.\n" + " --creator-il-ignore-profile \n" + - " Apply a maintainer-managed IL ignore profile and enable IL string filtering.\n" + + " Apply a maintainer-managed IL normalization profile.\n" + " --wizard Interactive mode: prompts for old/new folders and an optional report label.\n" + " Shows existing report folders before the label prompt; press Enter to auto-generate.\n" + " Drag-and-drop friendly.\n" + diff --git a/Runner/creator_il_ignore_profiles.json b/Runner/creator_il_ignore_profiles.json index 7bb0d701..9b08b7a4 100644 --- a/Runner/creator_il_ignore_profiles.json +++ b/Runner/creator_il_ignore_profiles.json @@ -1,14 +1,10 @@ { "profiles": [ { - "name": "buildserver-winforms", + "name": "creator-default", "strings": [ "buildserver1_", "buildserver2_", - "// Method begins at Relative Virtual Address (RVA) 0x", - ".publickeytoken = ( ", - ".custom instance void class [System.Windows.Forms]System.Windows.Forms.AxHost/TypeLibraryTimeStampAttribute::.ctor(string) = ( ", - "// Code size ", "¥temp¥develop¥", "¥¥temp¥¥develop¥¥", "\\temp\\develop\\", diff --git a/Services/ConfigService.cs b/Services/ConfigService.cs index 82a9aa80..edf67e65 100644 --- a/Services/ConfigService.cs +++ b/Services/ConfigService.cs @@ -21,6 +21,7 @@ public sealed class ConfigService " (e.g. remove the comma in \"Key\": \"value\",})."; internal const string ERROR_CONFIG_VALIDATION_PREFIX = "config.json contains invalid settings:"; internal const string ENV_VAR_PREFIX = "FOLDERDIFF_"; + internal const string REMOVED_SHOULD_IGNORE_MVID_ENV_VAR = ENV_VAR_PREFIX + "SHOULDIGNOREMVID"; internal const string RESOLVED_CONFIG_PATH_DATA_KEY = "FolderDiffIL4DotNet.ResolvedConfigFileAbsolutePath"; private readonly Func _bundledConfigPathResolver; @@ -63,6 +64,23 @@ public async Task LoadConfigBuilderAsync(string? configFi configFileAbsolutePath); } + if (builder.HasRemovedShouldIgnoreMvidSetting) + { + throw StampResolvedConfigFileAbsolutePath( + new InvalidDataException( + $"{ConfigSettingsBuilder.REMOVED_SHOULD_IGNORE_MVID_ERROR} File: '{configFileAbsolutePath}'."), + configFileAbsolutePath); + } + + if (Environment.GetEnvironmentVariable(REMOVED_SHOULD_IGNORE_MVID_ENV_VAR) != null) + { + throw StampResolvedConfigFileAbsolutePath( + new InvalidDataException( + $"Environment variable '{REMOVED_SHOULD_IGNORE_MVID_ENV_VAR}' has been removed. " + + "MVID values are always normalized; remove this environment variable."), + configFileAbsolutePath); + } + ApplyEnvironmentVariableOverrides(builder); return builder; @@ -164,6 +182,7 @@ internal static void ApplyEnvironmentVariableOverrides(ConfigSettingsBuilder con TryApplyBool(P + "SHOULDGENERATEHTMLREPORT", v => config.ShouldGenerateHtmlReport = v); TryApplyBool(P + "SHOULDOUTPUTILTEXT", v => config.ShouldOutputILText = v); TryApplyBool(P + "SHOULDIGNOREILLINESCONFIGUREDSTRINGS", v => config.ShouldIgnoreILLinesContainingConfiguredStrings = v); + TryApplyBool(P + "SHOULDILNORMALIZECONTAININGCONFIGUREDSTRINGS", v => config.ShouldILNormalizeContainingConfiguredStrings = v); TryApplyBool(P + "SHOULDOUTPUTFILETIMESTAMPS", v => config.ShouldOutputFileTimestamps = v); TryApplyBool(P + "SHOULDWARNWHENNEWFILETIMESTAMPISOLDER", v => config.ShouldWarnWhenNewFileTimestampIsOlderThanOldFileTimestamp = v); TryApplyInt(P + "MAXPARALLELISM", v => config.MaxParallelism = v); diff --git a/Services/DisassemblerHelper.cs b/Services/DisassemblerHelper.cs index cd08f3fe..be047919 100644 --- a/Services/DisassemblerHelper.cs +++ b/Services/DisassemblerHelper.cs @@ -228,7 +228,7 @@ internal static string BuildInstallSuggestion() sb.AppendLine(" [Windows]"); sb.AppendLine(" dotnet tool install -g dotnet-ildasm"); sb.AppendLine(" # Or alternatively / または:"); - sb.AppendLine(" dotnet tool install -g ilspycmd"); + sb.AppendLine($" dotnet tool install -g ilspycmd --version {Constants.ILSPY_CMD_VERSION}"); sb.AppendLine(); sb.AppendLine(" Ensure %USERPROFILE%\\.dotnet\\tools is in your PATH."); sb.AppendLine(" %USERPROFILE%\\.dotnet\\tools が PATH に含まれていることを確認してください。"); @@ -238,7 +238,7 @@ internal static string BuildInstallSuggestion() sb.AppendLine(" [macOS]"); sb.AppendLine(" dotnet tool install -g dotnet-ildasm"); sb.AppendLine(" # Or alternatively / または:"); - sb.AppendLine(" dotnet tool install -g ilspycmd"); + sb.AppendLine($" dotnet tool install -g ilspycmd --version {Constants.ILSPY_CMD_VERSION}"); sb.AppendLine(); sb.AppendLine(" Ensure ~/.dotnet/tools is in your PATH:"); sb.AppendLine(" ~/.dotnet/tools が PATH に含まれていることを確認してください:"); @@ -250,7 +250,7 @@ internal static string BuildInstallSuggestion() sb.AppendLine(" [Linux]"); sb.AppendLine(" dotnet tool install -g dotnet-ildasm"); sb.AppendLine(" # Or alternatively / または:"); - sb.AppendLine(" dotnet tool install -g ilspycmd"); + sb.AppendLine($" dotnet tool install -g ilspycmd --version {Constants.ILSPY_CMD_VERSION}"); sb.AppendLine(); sb.AppendLine(" Ensure ~/.dotnet/tools is in your PATH:"); sb.AppendLine(" ~/.dotnet/tools が PATH に含まれていることを確認してください:"); diff --git a/Services/DotNetDisassembleService.cs b/Services/DotNetDisassembleService.cs index 1332649c..1ef50935 100644 --- a/Services/DotNetDisassembleService.cs +++ b/Services/DotNetDisassembleService.cs @@ -45,7 +45,7 @@ public sealed partial class DotNetDisassembleService : IDotNetDisassembleService " " + Constants.DOTNET_MUXER + " tool install -g " + Constants.DOTNET_ILDASM + "\n" + "Also ensure that ~/.dotnet/tools is included in your PATH.\n" + "Alternatively, you can install " + Constants.ILSPY_CMD + " and we will use it automatically:\n" + - " " + Constants.DOTNET_MUXER + " tool install -g " + Constants.ILSPY_CMD; + " " + Constants.DOTNET_MUXER + " tool install -g " + Constants.ILSPY_CMD + " --version " + Constants.ILSPY_CMD_VERSION; private const int DEFAULT_BLACKLIST_TTL_MINUTES = 10; private readonly IReadOnlyConfigSettings _config; diff --git a/Services/FileDiffService.cs b/Services/FileDiffService.cs index 35b0c822..870249a0 100644 --- a/Services/FileDiffService.cs +++ b/Services/FileDiffService.cs @@ -162,9 +162,9 @@ public async Task FilesAreEqualAsync(string fileRelativePath, int maxParal return true; } - // 2) IL for .NET assemblies: delegated to a separate service because it involves assembly-specific processing (MVID / configured-string line exclusion). + // 2) IL for .NET assemblies: delegated to a separate service because it applies built-in IL-value normalization plus configured-substring normalization and line filtering. // When SkipIL is true, skip IL comparison and fall through to text/binary comparison. - // 2) .NET アセンブリなら IL: IL 比較は行除外(MVID や設定文字列)などアセンブリ固有処理を伴うため別サービスに委譲。 + // 2) .NET アセンブリなら IL: 組み込み IL 値の正規化、設定部分文字列の正規化と行除外を伴うため別サービスに委譲。 // SkipIL が true の場合は IL 比較をスキップしてテキスト/バイナリ比較に進む。 comparisonStage = "detecting .NET executable"; var dotNetDetectionResult = _config.SkipIL diff --git a/Services/FolderDiffService.cs b/Services/FolderDiffService.cs index d62f2227..2ed3107a 100644 --- a/Services/FolderDiffService.cs +++ b/Services/FolderDiffService.cs @@ -163,8 +163,8 @@ public async Task ExecuteFolderDiffAsync(CancellationToken cancellationToken = d LogDiscoveryStats(totalFilesRelativePathCount.Value, maxParallel.Value); currentPhase = "scanning assembly candidates"; ScanAssemblyCandidatesAndLog(); - currentPhase = "validating IL filter strings"; - ValidateILFilterStrings(); + currentPhase = "validating configured IL substrings"; + ValidateILConfiguredStrings(); currentPhase = "precomputing IL cache"; await PrecomputeIlCachesAsync(maxParallel.Value, cancellationToken); @@ -441,24 +441,18 @@ private void ProcessAddedFiles(IEnumerable remainingNewFiles, int proces } /// - /// Validates configured IL filter strings and records warnings for any that appear too short or overly broad. - /// Also logs each warning to the console. - /// 設定された IL フィルタ文字列を検証し、短すぎるまたは広範すぎるパターンに対する警告を記録します。 - /// 各警告はコンソールにも出力されます。 + /// Validates configured IL ignore and normalization substrings and records safety warnings, + /// including short values, duplicates, and containment relationships. Also logs each warning to the console. + /// 設定された IL 行無視および正規化の部分文字列を検証し、短い値、重複、包含関係などの + /// 安全性警告を記録します。各警告はコンソールにも出力されます。 /// - private void ValidateILFilterStrings() + private void ValidateILConfiguredStrings() { - if (_config.ILIgnoreLineContainingStrings == null || _config.ILIgnoreLineContainingStrings.Count == 0) - return; - - var normalized = _config.ILIgnoreLineContainingStrings - .Where(value => !string.IsNullOrWhiteSpace(value)) - .Select(value => value.Trim()) - .Distinct(StringComparer.Ordinal) - .ToList(); - - var warnings = ILOutputService.ValidateILFilterStrings(normalized); - foreach (var warning in warnings) + var normalizationWarnings = _config.ShouldILNormalizeContainingConfiguredStrings + ? ILOutputService.ValidateILNormalizeContainingStrings(_config.ILNormalizeContainingStrings) + : new List(); + var ignoreWarnings = ILOutputService.ValidateILIgnoreContainingStrings(_config.ILIgnoreLineContainingStrings); + foreach (var warning in normalizationWarnings.Concat(ignoreWarnings)) { _fileDiffResultLists.ILFilterWarnings.Add(warning); _logger.LogMessage(AppLogLevel.Warning, warning, shouldOutputMessageToConsole: true); diff --git a/Services/HtmlReport/HtmlReportGenerateService.Helpers.cs b/Services/HtmlReport/HtmlReportGenerateService.Helpers.cs index 125aae1f..30ef28c6 100644 --- a/Services/HtmlReport/HtmlReportGenerateService.Helpers.cs +++ b/Services/HtmlReport/HtmlReportGenerateService.Helpers.cs @@ -400,16 +400,6 @@ private static int GetModifiedSortOrder(FileDiffResultLists.DiffDetailResult det _ => 3 }; - private static List GetNormalizedIlIgnoreStrings(IReadOnlyConfigSettings config) - { - if (config?.ILIgnoreLineContainingStrings == null) return new List(); - return config.ILIgnoreLineContainingStrings - .Where(v => !string.IsNullOrWhiteSpace(v)) - .Select(v => v.Trim()) - .Distinct(StringComparer.Ordinal) - .ToList(); - } - private static void AppendReviewChecklistSection(TextWriter writer, IReadOnlyList items) { if (items.Count == 0) diff --git a/Services/HtmlReport/HtmlReportGenerateService.Sections.cs b/Services/HtmlReport/HtmlReportGenerateService.Sections.cs index ce1ba32c..24cbb6da 100644 --- a/Services/HtmlReport/HtmlReportGenerateService.Sections.cs +++ b/Services/HtmlReport/HtmlReportGenerateService.Sections.cs @@ -51,34 +51,61 @@ private void AppendHeaderSection( // Text File Extensions (standalone rounded section) / テキストファイル拡張子(独立した角丸セクション) writer.WriteLine($"
Text File Extensions
{HtmlEncode(string.Join(", ", config.TextFileExtensions))}
"); - // IL Ignored Strings (standalone rounded section, comma-separated) / IL 無視文字列(独立した角丸セクション、カンマ区切り) - if (config.ShouldIgnoreILLinesContainingConfiguredStrings) + writer.WriteLine($"
{HtmlEncode("Built-in IL Normalization")}
"); + writer.WriteLine($"
{HtmlEncode("Rules apply in the listed order to all IL text, preserving each matching prefix and replacing only its build-variant value.")}
"); + writer.WriteLine("
"); + writer.WriteLine($""); + writer.WriteLine(""); + foreach (var rule in ILOutputService.BuiltInNormalizationRules) { - var ilIgnoreStrings = GetNormalizedIlIgnoreStrings(config); - if (ilIgnoreStrings.Count == 0) - { - writer.WriteLine($"
{HtmlEncode("IL Ignored Strings")}
{HtmlEncode("Enabled, but no non-empty strings are configured.")}
"); - } - else - { - writer.WriteLine($"
{HtmlEncode("IL Ignored Strings")}
"); - foreach (var s in ilIgnoreStrings) - { - writer.WriteLine($"
{HtmlEncode($"\"{s}\"")}
"); - } - writer.WriteLine("
"); - } + writer.WriteLine($""); + } + writer.WriteLine("
{HtmlEncode("Line Prefix Pattern")}{HtmlEncode("Replacement")}{HtmlEncode("Observed Output From")}
{HtmlEncode(rule.Prefix)}{HtmlEncode(rule.Replacement.TrimStart())}{HtmlEncode(rule.Disassembler)}
"); + writer.WriteLine("
"); + + var ilNormalizeStrings = ILConfiguredSubstringHelper.GetEffectiveNormalizationSubstrings(config.ILNormalizeContainingStrings); + if (config.ShouldILNormalizeContainingConfiguredStrings) + { + writer.WriteLine($"
{HtmlEncode("IL Normalize Containing Strings")}
"); + writer.WriteLine($"
{HtmlEncode("Matches are replaced in the listed order with a comparison-local marker absent from both inputs; all other text remains comparable.")}
"); + AppendConfiguredStringTableOrEmptyMessage(writer, "Substring to Normalize", ilNormalizeStrings); + writer.WriteLine("
"); } - // MVID note — only shown when MVID lines are actually ignored / MVID ノート — MVID 行が実際に除外される場合のみ表示 - if (config.ShouldIgnoreMVID) + // Configured IL ignore strings / 設定された IL 無視文字列 + if (config.ShouldIgnoreILLinesContainingConfiguredStrings) { - writer.WriteLine($"
{HtmlEncode("IL Diff Note")}
{HtmlEncode("Lines starting with")} {HtmlEncode(Constants.IL_MVID_LINE_PREFIX)} {HtmlEncode("are auto-ignored (Module Version ID metadata).")}
"); + var ilIgnoreStrings = ILConfiguredSubstringHelper.GetEffectiveIgnoreLineSubstrings(config.ILIgnoreLineContainingStrings); + writer.WriteLine($"
{HtmlEncode("IL Ignore Line Containing Strings")}
"); + writer.WriteLine($"
{HtmlEncode("Each IL line containing any configured substring is excluded from comparison.")}
"); + AppendConfiguredStringTableOrEmptyMessage(writer, "Substring to Ignore", ilIgnoreStrings); + writer.WriteLine("
"); } writer.WriteLine(""); // report-header } + private static void AppendConfiguredStringTableOrEmptyMessage( + TextWriter writer, + string columnHeader, + IReadOnlyList values) + { + if (values.Count == 0) + { + writer.WriteLine($"
{HtmlEncode("Enabled, but no non-empty strings are configured.")}
"); + return; + } + + writer.WriteLine($"
"); + writer.WriteLine($""); + writer.WriteLine(""); + foreach (var value in values) + { + writer.WriteLine($""); + } + writer.WriteLine("
{HtmlEncode(columnHeader)}
{HtmlEncode(value)}
"); + } + /// Appends a single header card element. / ヘッダーカード要素を1つ追加します。 private static void AppendHeaderCard(TextWriter writer, string label, string value, string? cssClass = null) { @@ -329,14 +356,14 @@ private void AppendWarningsSection( writer.WriteLine($"

{HtmlEncode("Warnings")}

"); - // IL filter string validation warnings - // IL フィルタ文字列検証警告 + // Configured IL substring safety warnings + // 設定された IL 部分文字列の安全性警告 if (hasILFilter) { - var filterWarnings = _fileDiffResultLists.ILFilterWarnings.OrderBy(w => w, StringComparer.Ordinal).ToList(); - writer.WriteLine($"

[ ! ] {HtmlEncode("IL filter validation warnings")} ({filterWarnings.Count})

"); + var ilSubstringWarnings = _fileDiffResultLists.ILFilterWarnings.OrderBy(w => w, StringComparer.Ordinal).ToList(); + writer.WriteLine($"

[ ! ] {HtmlEncode("IL substring configuration safety warnings")} ({ilSubstringWarnings.Count})

"); writer.WriteLine("
    "); - foreach (var w in filterWarnings) + foreach (var w in ilSubstringWarnings) { writer.WriteLine($"
  • {HtmlEncode(w)}
  • "); } diff --git a/Services/HtmlReport/diff_report.css b/Services/HtmlReport/diff_report.css index 74d01226..7560e9d6 100644 --- a/Services/HtmlReport/diff_report.css +++ b/Services/HtmlReport/diff_report.css @@ -55,6 +55,11 @@ font-size: 13px; color: var(--color-text); word-break: break-all; line-height: 1.5; } + .header-config-description { margin-bottom: 0.55rem; word-break: normal; } + .header-config-table-scroll { overflow-x: auto; } + table.header-config-table { margin: 0; max-width: 100%; } + table.header-config-table td { vertical-align: top; word-break: break-word; } + table.header-config-table code { white-space: pre-wrap; overflow-wrap: anywhere; } /* ── Legend table ─────────────────────────────────────────────────── */ table.legend-table { width: auto; margin: 4px 0 2px; border-collapse: collapse; } table.legend-table th { border: 1px solid var(--color-border-strong); font-size: 13px; padding: 3px 10px; text-align: left; white-space: nowrap; } diff --git a/Services/ILConfiguredSubstringHelper.cs b/Services/ILConfiguredSubstringHelper.cs new file mode 100644 index 00000000..26adb40c --- /dev/null +++ b/Services/ILConfiguredSubstringHelper.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FolderDiffIL4DotNet.Models; + +namespace FolderDiffIL4DotNet.Services +{ + /// + /// Produces effective configured substring lists shared by IL comparison and reports. + /// IL 比較とレポートで共有する実効設定部分文字列リストを生成します。 + /// + internal static class ILConfiguredSubstringHelper + { + internal static List GetEffectiveIgnoreLineSubstrings(IReadOnlyList? configuredStrings) + { + return GetNonEmptyDistinct(configuredStrings, trimValues: true); + } + + internal static List GetEffectiveNormalizationSubstrings(IReadOnlyList? configuredStrings) + { + string? limitError = ConfigSettings.GetILNormalizeContainingStringsLimitError(configuredStrings); + if (limitError != null) + { + throw new InvalidOperationException($"Invalid runtime configuration: {limitError}"); + } + + return GetNonEmptyDistinct(configuredStrings, trimValues: false); + } + + private static List GetNonEmptyDistinct( + IReadOnlyList? configuredStrings, + bool trimValues) + { + if (configuredStrings == null) + { + return new List(); + } + + return configuredStrings + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => trimValues ? value.Trim() : value) + .Distinct(StringComparer.Ordinal) + .ToList(); + } + } +} diff --git a/Services/ILOutput/IILTextOutputService.cs b/Services/ILOutput/IILTextOutputService.cs index 1cbd3ea5..5999ad3f 100644 --- a/Services/ILOutput/IILTextOutputService.cs +++ b/Services/ILOutput/IILTextOutputService.cs @@ -10,12 +10,18 @@ namespace FolderDiffIL4DotNet.Services.ILOutput public interface IILTextOutputService { /// - /// Writes all old/new IL text lines (with MVID excluded) to files. - /// MVID を除外した old/new の IL テキスト全行をファイルに書き込みます。 + /// Writes filtered and normalized old/new IL text lines to files. + /// フィルタ・正規化済みの old/new IL テキスト行をファイルに書き込みます。 /// /// Relative path of the target file (used to generate output file names). / 対象ファイルの相対パス(出力ファイル名の生成に使用します)。 - /// MVID-excluded IL text lines for the old side. / old 側の MVID 除外済み IL テキスト行。 - /// MVID-excluded IL text lines for the new side. / new 側の MVID 除外済み IL テキスト行。 - Task WriteFullIlTextsAsync(string fileRelativePath, IEnumerable il1LinesMvidExcluded, IEnumerable il2LinesMvidExcluded); + /// + /// Filtered and normalized IL text lines for the old side. + /// old 側のフィルタ・正規化済み IL テキスト行。 + /// + /// + /// Filtered and normalized IL text lines for the new side. + /// new 側のフィルタ・正規化済み IL テキスト行。 + /// + Task WriteFullIlTextsAsync(string fileRelativePath, IEnumerable filteredIl1Lines, IEnumerable filteredIl2Lines); } } diff --git a/Services/ILOutput/ILTextOutputService.cs b/Services/ILOutput/ILTextOutputService.cs index 94d0ecca..745793a9 100644 --- a/Services/ILOutput/ILTextOutputService.cs +++ b/Services/ILOutput/ILTextOutputService.cs @@ -10,8 +10,8 @@ namespace FolderDiffIL4DotNet.Services.ILOutput { /// - /// Generates *_IL.txt files (old/new) containing IL text with excluded lines removed, and marks them read-only. - /// *_IL.txt (old/new) の生成を担当するサービス。比較時に除外した行を除いた内容を保存し、読み取り専用属性を付与します。 + /// Generates *_IL.txt files (old/new) from filtered and normalized IL text and marks them read-only on a best-effort basis. + /// フィルタ・正規化済みの IL テキストから *_IL.txt (old/new) を生成し、ベストエフォートで読み取り専用属性を付与します。 /// public sealed class ILTextOutputService : IILTextOutputService { @@ -36,10 +36,10 @@ public ILTextOutputService(DiffExecutionContext executionContext, ILoggerService } /// - /// Writes full old/new IL text (with excluded lines removed) to *_IL.txt and marks them read-only. - /// old/new 両側の IL 全文テキストを *_IL.txt に出力(除外行適用後)し、読み取り専用化します。 + /// Writes filtered and normalized old/new IL text to *_IL.txt and marks the files read-only on a best-effort basis. + /// フィルタ・正規化済みの old/new IL テキストを *_IL.txt に出力し、ベストエフォートで読み取り専用属性を付与します。 /// - public async Task WriteFullIlTextsAsync(string fileRelativePath, IEnumerable il1LinesMvidExcluded, IEnumerable il2LinesMvidExcluded) + public async Task WriteFullIlTextsAsync(string fileRelativePath, IEnumerable filteredIl1Lines, IEnumerable filteredIl2Lines) { string oldILFileAbsolutePath = string.Empty; string newILFileAbsolutePath = string.Empty; @@ -60,8 +60,8 @@ public async Task WriteFullIlTextsAsync(string fileRelativePath, IEnumerable public sealed partial class ILOutputService { + private const string NORMALIZED_VALUE_PREFIX = ""; + internal const string MVID_NORMALIZED_VALUE = NORMALIZED_VALUE_PREFIX + "mvid>"; + internal const string CODE_SIZE_NORMALIZED_VALUE = NORMALIZED_VALUE_PREFIX + "code-size>"; + internal const string TYPE_LIBRARY_TIMESTAMP_NORMALIZED_VALUE = NORMALIZED_VALUE_PREFIX + "type-library-timestamp>"; + internal const string CONFIGURED_NORMALIZED_VALUE = NORMALIZED_VALUE_PREFIX + "configured-value>"; + internal const int MAX_CONFIGURED_NORMALIZATION_REPLACEMENTS_PER_LINE = 65_536; + internal const int MAX_CONFIGURED_NORMALIZED_LINE_UTF16_CODE_UNITS = 4 * 1024 * 1024; + private const int CONFIGURED_NORMALIZATION_INITIAL_SEGMENT_GROWTH = 256; + private const int IL_NORMALIZATION_WARNING_DETAIL_LIMIT = 100; + + internal static IReadOnlyList<(string Disassembler, string Prefix, string Replacement)> BuiltInNormalizationRules { get; } = + Array.AsReadOnly( + new[] + { + (Disassembler: Constants.DOTNET_ILDASM, Prefix: Constants.IL_MVID_LINE_PREFIX, Replacement: $" {MVID_NORMALIZED_VALUE}"), + (Disassembler: Constants.DOTNET_ILDASM, Prefix: Constants.IL_RVA_LINE_PREFIX, Replacement: RVA_NORMALIZED_VALUE), + (Disassembler: Constants.ILSPY_CMD, Prefix: Constants.IL_ILSPY_RVA_LINE_PREFIX, Replacement: RVA_NORMALIZED_VALUE), + (Disassembler: Constants.DOTNET_ILDASM, Prefix: Constants.IL_TYPE_LIBRARY_TIMESTAMP_LINE_PREFIX, Replacement: TYPE_LIBRARY_TIMESTAMP_NORMALIZED_VALUE), + (Disassembler: Constants.ILSPY_CMD, Prefix: Constants.IL_ILSPY_TYPE_LIBRARY_TIMESTAMP_LINE_PREFIX, Replacement: $" {TYPE_LIBRARY_TIMESTAMP_NORMALIZED_VALUE}"), + (Disassembler: Constants.DOTNET_ILDASM, Prefix: Constants.IL_CODE_SIZE_LINE_PREFIX, Replacement: CODE_SIZE_NORMALIZED_VALUE), + (Disassembler: Constants.ILSPY_CMD, Prefix: Constants.IL_ILSPY_CODE_SIZE_LINE_PREFIX, Replacement: CODE_SIZE_NORMALIZED_VALUE) + } + .OrderBy(rule => rule.Prefix, StringComparer.Ordinal) + .ThenBy(rule => rule.Disassembler, StringComparer.Ordinal) + .ToArray()); + /// /// Compares two IL line collections after applying exclusion filters, without materializing - /// the filtered results into separate lists. Advances dual indices, skipping excluded lines, - /// and short-circuits on the first mismatch — O(1) extra memory beyond the input lists. + /// the complete filtered results into separate lists. Advances dual indices, skipping excluded + /// lines, and short-circuits on the first mismatch. Configured normalization uses a bounded + /// representation of the current line, subject to explicit replacement and output limits; + /// collision-free marker selection also tracks marker suffixes already present across the inputs. /// 2 つの IL 行コレクションを除外フィルタ適用後に比較します。フィルタ済みの別リストを - /// 実体化せずに 2 つのインデックスを進め、除外行をスキップしながら最初の不一致で即終了します。 - /// 入力リスト以外の追加メモリは O(1) です。 + /// 全体として実体化せずに 2 つのインデックスを進め、除外行をスキップしながら最初の不一致で + /// 即終了します。設定値の正規化では、明示的な置換数・出力長上限の範囲内で現在行を有界な + /// 表現として扱い、衝突しないマーカーの選択では入力全体に既存のマーカー suffix も追跡します。 /// + /// + /// A matching line exceeds a configured-normalization replacement or output limit. + /// 一致する行が設定値正規化の置換数または出力長上限を超えた場合に発生します。 + /// internal static bool StreamingFilteredSequenceEqual( IReadOnlyList lines1, IReadOnlyList lines2, bool shouldIgnoreContainingStrings, IReadOnlyCollection ilIgnoreContainingStrings, - bool shouldIgnoreMVID = true) + IReadOnlyCollection ilNormalizeContainingStrings) + { + string configuredNormalizedValue = ilNormalizeContainingStrings.Count == 0 + ? CONFIGURED_NORMALIZED_VALUE + : CreateCollisionFreeConfiguredNormalizedValue(lines1, lines2); + return StreamingFilteredSequenceEqual( + lines1, + lines2, + shouldIgnoreContainingStrings, + ilIgnoreContainingStrings, + ilNormalizeContainingStrings, + configuredNormalizedValue); + } + + private static bool StreamingFilteredSequenceEqual( + IReadOnlyList lines1, + IReadOnlyList lines2, + bool shouldIgnoreContainingStrings, + IReadOnlyCollection ilIgnoreContainingStrings, + IReadOnlyCollection ilNormalizeContainingStrings, + string configuredNormalizedValue) { int i = 0; int j = 0; - int count1 = lines1.Count; - int count2 = lines2.Count; while (true) { - while (i < count1 && ShouldExcludeIlLine(lines1[i], shouldIgnoreContainingStrings, ilIgnoreContainingStrings, shouldIgnoreMVID)) - { - i++; - } - - while (j < count2 && ShouldExcludeIlLine(lines2[j], shouldIgnoreContainingStrings, ilIgnoreContainingStrings, shouldIgnoreMVID)) - { - j++; - } + bool hasLine1 = TryReadNextFilteredNormalizedLine( + lines1, + ref i, + shouldIgnoreContainingStrings, + ilIgnoreContainingStrings, + ilNormalizeContainingStrings, + configuredNormalizedValue, + out string normalized1); + bool hasLine2 = TryReadNextFilteredNormalizedLine( + lines2, + ref j, + shouldIgnoreContainingStrings, + ilIgnoreContainingStrings, + ilNormalizeContainingStrings, + configuredNormalizedValue, + out string normalized2); - bool end1 = i >= count1; - bool end2 = j >= count2; + bool end1 = !hasLine1; + bool end2 = !hasLine2; if (end1 && end2) { return true; @@ -64,53 +124,213 @@ internal static bool StreamingFilteredSequenceEqual( // variations between disassembler versions or formatting differences. // 逆アセンブラバージョン間のインデント差異やフォーマット差異を吸収するため // 先頭・末尾空白をトリムして比較する。 - if (!lines1[i].AsSpan().Trim().SequenceEqual(lines2[j].AsSpan().Trim())) + if (!normalized1.AsSpan().Trim().SequenceEqual(normalized2.AsSpan().Trim())) { return false; } - - i++; - j++; } } /// - /// Filters IL lines by applying exclusion rules, returning a new list of non-excluded lines. - /// Used when the filtered result must be materialized (e.g. for IL text file output). - /// IL 行を除外ルールでフィルタリングし、除外されなかった行の新しいリストを返します。 - /// フィルタ結果の実体化が必要な場合(IL テキストファイル出力等)に使用します。 + /// Filters and normalizes IL lines, returning a materialized list of comparable lines. + /// Used when the result must be retained, for example for IL text file output. + /// IL 行をフィルタリングおよび正規化し、比較可能な行を実体化したリストとして返します。 + /// IL テキストファイル出力など、結果の保持が必要な場合に使用します。 /// + /// + /// A matching line exceeds a configured-normalization replacement or output limit. + /// 一致する行が設定値正規化の置換数または出力長上限を超えた場合に発生します。 + /// internal static List FilterIlLines( IReadOnlyList lines, bool shouldIgnoreContainingStrings, IReadOnlyCollection ilIgnoreContainingStrings, - bool shouldIgnoreMVID = true) + IReadOnlyCollection ilNormalizeContainingStrings) + { + string configuredNormalizedValue = ilNormalizeContainingStrings.Count == 0 + ? CONFIGURED_NORMALIZED_VALUE + : CreateCollisionFreeConfiguredNormalizedValue(lines); + return FilterIlLines( + lines, + shouldIgnoreContainingStrings, + ilIgnoreContainingStrings, + ilNormalizeContainingStrings, + configuredNormalizedValue); + } + + private static List FilterIlLines( + IReadOnlyList lines, + bool shouldIgnoreContainingStrings, + IReadOnlyCollection ilIgnoreContainingStrings, + IReadOnlyCollection ilNormalizeContainingStrings, + string configuredNormalizedValue) { var result = new List(lines.Count); - for (int i = 0; i < lines.Count; i++) + int lineIndex = 0; + while (TryReadNextFilteredNormalizedLine( + lines, + ref lineIndex, + shouldIgnoreContainingStrings, + ilIgnoreContainingStrings, + ilNormalizeContainingStrings, + configuredNormalizedValue, + out string normalizedLine)) + { + result.Add(normalizedLine); + } + + return result; + } + + private static bool TryReadNextFilteredNormalizedLine( + IReadOnlyList lines, + ref int lineIndex, + bool shouldIgnoreContainingStrings, + IReadOnlyCollection ilIgnoreContainingStrings, + IReadOnlyCollection ilNormalizeContainingStrings, + string configuredNormalizedValue, + out string normalizedLine) + { + while (lineIndex < lines.Count) { - if (!ShouldExcludeIlLine(lines[i], shouldIgnoreContainingStrings, ilIgnoreContainingStrings, shouldIgnoreMVID)) + int startIndex = lineIndex; + bool isIlspyTypeLibraryTimestampBlock = TryNormalizeIlspyTypeLibraryTimestampBlock( + lines, + startIndex, + out int endIndexExclusive, + out string candidateLine); + if (!isIlspyTypeLibraryTimestampBlock) + { + endIndexExclusive = startIndex + 1; + candidateLine = lines[startIndex]; + } + + lineIndex = endIndexExclusive; + if (ShouldExcludeIlLine( + lines[startIndex], + shouldIgnoreContainingStrings, + ilIgnoreContainingStrings)) { - result.Add(lines[i]); + continue; } + + normalizedLine = NormalizeIlLine( + candidateLine, + ilNormalizeContainingStrings, + configuredNormalizedValue); + return true; } - return result; + normalizedLine = string.Empty; + return false; + } + + private static bool TryNormalizeIlspyTypeLibraryTimestampBlock( + IReadOnlyList lines, + int startIndex, + out int endIndexExclusive, + out string normalizedLine) + { + string headerLine = lines[startIndex]; + ReadOnlySpan content = headerLine.AsSpan().TrimStart(); + if (!content.StartsWith(Constants.IL_ILSPY_TYPE_LIBRARY_TIMESTAMP_LINE_PREFIX, StringComparison.Ordinal)) + { + endIndexExclusive = startIndex + 1; + normalizedLine = headerLine; + return false; + } + + // ilspycmd emits custom-attribute blobs over multiple lines. Collapse the complete + // timestamp blob so its byte payload cannot leak into line or block comparison. + // ilspycmd は custom attribute の blob を複数行で出力するため、timestamp blob 全体を + // 縮約し、バイト列が行比較やブロック比較へ残らないようにする。 + bool hasPayloadLine = false; + for (int currentIndex = startIndex + 1; currentIndex < lines.Count; currentIndex++) + { + if (lines[currentIndex].AsSpan().Trim().SequenceEqual(")".AsSpan())) + { + if (!hasPayloadLine) + { + break; + } + + endIndexExclusive = currentIndex + 1; + normalizedLine = NormalizeSuffixAfterPrefix( + headerLine, + Constants.IL_ILSPY_TYPE_LIBRARY_TIMESTAMP_LINE_PREFIX, + $" {TYPE_LIBRARY_TIMESTAMP_NORMALIZED_VALUE}"); + return true; + } + + if (!IsIlspyCustomAttributeBlobPayloadLine(lines[currentIndex])) + { + break; + } + + hasPayloadLine = true; + } + + endIndexExclusive = startIndex + 1; + normalizedLine = headerLine; + return false; + } + + private static bool IsIlspyCustomAttributeBlobPayloadLine(string line) + { + ReadOnlySpan content = line.AsSpan().Trim(); + if (content.IsEmpty) + { + return false; + } + + int tokenLength = 0; + bool hasByte = false; + foreach (char value in content) + { + if (char.IsWhiteSpace(value)) + { + if (tokenLength != 0 && tokenLength != 2) + { + return false; + } + + hasByte |= tokenLength == 2; + tokenLength = 0; + continue; + } + + if (!Uri.IsHexDigit(value) || ++tokenLength > 2) + { + return false; + } + } + + if (tokenLength != 0 && tokenLength != 2) + { + return false; + } + + return hasByte || tokenLength == 2; } /// - /// Splits IL text into lines and filters out excluded lines in a single pass, - /// avoiding intermediate list allocations from separate Split → Where → ToList chains. - /// IL テキストを行に分割し、除外行を 1 パスでフィルタリングすることで - /// Split → Where → ToList の中間リスト割り当てを回避します。 + /// Splits IL text into lines, collapses supported multiline ILSpy timestamp forms, + /// and applies filtering and normalization in a single pass. + /// IL テキストを行に分割し、対応する ILSpy の複数行 timestamp 形式をまとめて、 + /// フィルタリングと正規化を 1 パスで適用します。 /// + /// + /// A matching line exceeds a configured-normalization replacement or output limit. + /// 一致する行が設定値正規化の置換数または出力長上限を超えた場合に発生します。 + /// private static List SplitAndFilterIlLines( string ilText, bool shouldIgnoreContainingStrings, IReadOnlyCollection ilIgnoreContainingStrings, - bool shouldIgnoreMVID = true) + IReadOnlyCollection ilNormalizeContainingStrings) { var result = new List(); + List? pendingIlspyTimestampBlock = null; int startIndex = 0; int length = ilText.Length; while (startIndex <= length) @@ -128,13 +348,64 @@ private static List SplitAndFilterIlLines( startIndex = newlineIndex + 1; } - if (!ShouldExcludeIlLine(line, shouldIgnoreContainingStrings, ilIgnoreContainingStrings, shouldIgnoreMVID)) + if (pendingIlspyTimestampBlock != null) + { + bool isClosingLine = line.AsSpan().Trim().SequenceEqual(")".AsSpan()); + if (isClosingLine && pendingIlspyTimestampBlock.Count > 1) + { + pendingIlspyTimestampBlock.Add(line); + result.AddRange(FilterIlLines( + pendingIlspyTimestampBlock, + shouldIgnoreContainingStrings, + ilIgnoreContainingStrings, + ilNormalizeContainingStrings)); + pendingIlspyTimestampBlock = null; + continue; + } + + if (IsIlspyCustomAttributeBlobPayloadLine(line)) + { + pendingIlspyTimestampBlock.Add(line); + continue; + } + + AddOrdinaryLines(pendingIlspyTimestampBlock); + pendingIlspyTimestampBlock = null; + } + + if (line.AsSpan().TrimStart().StartsWith( + Constants.IL_ILSPY_TYPE_LIBRARY_TIMESTAMP_LINE_PREFIX, + StringComparison.Ordinal)) { - result.Add(line); + pendingIlspyTimestampBlock = new List { line }; + continue; } + + AddOrdinaryLine(line); + } + + if (pendingIlspyTimestampBlock != null) + { + AddOrdinaryLines(pendingIlspyTimestampBlock); } return result; + + void AddOrdinaryLines(IEnumerable lines) + { + foreach (string pendingLine in lines) + { + AddOrdinaryLine(pendingLine); + } + } + + void AddOrdinaryLine(string candidate) + { + if (!ShouldExcludeIlLine(candidate, shouldIgnoreContainingStrings, ilIgnoreContainingStrings)) + { + result.Add(NormalizeIlLine(candidate, ilNormalizeContainingStrings)); + } + } } /// @@ -144,25 +415,458 @@ private static List SplitAndFilterIlLines( private static bool ShouldExcludeIlLine( string line, bool shouldIgnoreContainingStrings, - IReadOnlyCollection ilIgnoreContainingStrings, - bool shouldIgnoreMVID = true) + IReadOnlyCollection ilIgnoreContainingStrings) { if (line is null) { return false; } - if (shouldIgnoreMVID && line.StartsWith(Constants.IL_MVID_LINE_PREFIX, StringComparison.Ordinal)) + if (!shouldIgnoreContainingStrings || ilIgnoreContainingStrings.Count == 0) { - return true; + return false; } - if (!shouldIgnoreContainingStrings || ilIgnoreContainingStrings == null || ilIgnoreContainingStrings.Count == 0) + return ilIgnoreContainingStrings.Any(target => line.Contains(target, StringComparison.Ordinal)); + } + + /// + /// Replaces known build-variant IL values and configured matching substrings with stable placeholders. + /// 既知のビルド依存 IL 値と設定された一致部分を安定したプレースホルダーへ置換します。 + /// + /// + /// The line has a configured match and exceeds a configured-normalization replacement or output limit. + /// 行に設定値の一致があり、設定値正規化の置換数または出力長上限を超えた場合に発生します。 + /// + internal static string NormalizeIlLine( + string line, + IReadOnlyCollection ilNormalizeContainingStrings) + { + string configuredNormalizedValue = ilNormalizeContainingStrings.Count == 0 || line is null + ? CONFIGURED_NORMALIZED_VALUE + : CreateCollisionFreeConfiguredNormalizedValue(new[] { line }); + return NormalizeIlLine( + line, + ilNormalizeContainingStrings, + configuredNormalizedValue); + } + + private static string NormalizeIlLine( + string? line, + IReadOnlyCollection ilNormalizeContainingStrings, + string configuredNormalizedValue) + { + if (line is null) { - return false; + return string.Empty; } - return ilIgnoreContainingStrings.Any(target => line.Contains(target, StringComparison.Ordinal)); + string normalized = line; + foreach (var rule in BuiltInNormalizationRules) + { + normalized = NormalizeSuffixAfterPrefix(normalized, rule.Prefix, rule.Replacement); + } + + if (ilNormalizeContainingStrings.Count == 0) + { + return normalized; + } + + return ApplyConfiguredNormalization( + normalized, + ilNormalizeContainingStrings, + configuredNormalizedValue); + } + + private static string ApplyConfiguredNormalization( + string line, + IReadOnlyCollection configuredStrings, + string configuredNormalizedValue) + { + // Keep generated markers in protected segments so later rules inspect only unreplaced input fragments. + // 生成したマーカーを保護セグメントに保持し、後続規則は未置換の入力断片だけを照合します。 + var segments = new List + { + new( + line, + StartIndex: 0, + Length: line.Length, + IsProtectedMarker: false, + RepetitionCount: 1) + }; + int totalReplacementCount = 0; + long outputLength = line.Length; + + foreach (string target in configuredStrings) + { + if (string.IsNullOrWhiteSpace(target)) + { + continue; + } + + int ruleMatchCount = CountConfiguredNormalizationMatches( + segments, + target, + MAX_CONFIGURED_NORMALIZATION_REPLACEMENTS_PER_LINE - totalReplacementCount); + if (ruleMatchCount == 0) + { + continue; + } + + // Track the logical length without materializing intermediate output. A later rule can shrink it. + // 中間出力を実体化せず論理長だけを追跡します。後続規則で短くなる場合があります。 + long projectedOutputLength = CalculateConfiguredNormalizationOutputLength( + outputLength, + ruleMatchCount, + target.Length, + configuredNormalizedValue.Length); + segments = ApplyConfiguredNormalizationRule( + segments, + target, + configuredNormalizedValue, + ruleMatchCount); + totalReplacementCount = checked(totalReplacementCount + ruleMatchCount); + outputLength = projectedOutputLength; + } + + // The output limit applies only when configured normalization actually replaces a match. + // 設定値正規化で実際に一致を置換する場合にだけ、出力長上限を適用します。 + if (totalReplacementCount == 0) + { + return line; + } + + if (outputLength < 0 + || outputLength > MAX_CONFIGURED_NORMALIZED_LINE_UTF16_CODE_UNITS) + { + throw CreateConfiguredNormalizationOutputLimitException(); + } + + // Enforce the final output limit immediately before allocating its contiguous storage. + // 最終出力の連続領域を割り当てる直前に出力長上限を適用します。 + int boundedOutputLength = checked((int)outputLength); + var result = new StringBuilder(boundedOutputLength); + foreach (ConfiguredNormalizationSegment segment in segments) + { + for (int repetition = 0; repetition < segment.RepetitionCount; repetition++) + { + result.Append(segment.Source, segment.StartIndex, segment.Length); + } + } + return result.ToString(); + } + + private static int CountConfiguredNormalizationMatches( + IReadOnlyList segments, + string target, + int remainingReplacementCount) + { + int matchCount = 0; + foreach (ConfiguredNormalizationSegment segment in segments) + { + if (segment.IsProtectedMarker) + { + continue; + } + + int endIndex = checked(segment.StartIndex + segment.Length); + int startIndex = segment.StartIndex; + while (true) + { + int matchIndex = segment.Source.IndexOf( + target, + startIndex, + endIndex - startIndex, + StringComparison.Ordinal); + if (matchIndex < 0) + { + break; + } + + if (matchCount >= remainingReplacementCount) + { + throw CreateConfiguredNormalizationReplacementLimitException(); + } + + matchCount = checked(matchCount + 1); + startIndex = checked(matchIndex + target.Length); + } + } + + return matchCount; + } + + private static long CalculateConfiguredNormalizationOutputLength( + long currentOutputLength, + int replacementCount, + int targetLength, + int replacementLength) + { + try + { + long lengthDeltaPerReplacement = checked((long)replacementLength - targetLength); + long totalLengthDelta = checked(lengthDeltaPerReplacement * replacementCount); + long projectedOutputLength = checked(currentOutputLength + totalLengthDelta); + if (projectedOutputLength < 0) + { + throw CreateConfiguredNormalizationOutputLimitException(); + } + + return projectedOutputLength; + } + catch (OverflowException) + { + throw CreateConfiguredNormalizationOutputLimitException(); + } + } + + private static InvalidDataException CreateConfiguredNormalizationReplacementLimitException() + { + return new InvalidDataException( + $"{nameof(ConfigSettings.ILNormalizeContainingStrings)}: configured IL normalization exceeded the per-line replacement limit of {MAX_CONFIGURED_NORMALIZATION_REPLACEMENTS_PER_LINE} matches."); + } + + private static InvalidDataException CreateConfiguredNormalizationOutputLimitException() + { + return new InvalidDataException( + $"{nameof(ConfigSettings.ILNormalizeContainingStrings)}: configured IL normalization would exceed the per-line output limit of {MAX_CONFIGURED_NORMALIZED_LINE_UTF16_CODE_UNITS} UTF-16 code units."); + } + + private static List ApplyConfiguredNormalizationRule( + List segments, + string target, + string configuredNormalizedValue, + int ruleMatchCount) + { + int maximumSegmentCount = checked(segments.Count + checked(ruleMatchCount * 2)); + int initialSegmentCount = Math.Min( + maximumSegmentCount, + checked(segments.Count + CONFIGURED_NORMALIZATION_INITIAL_SEGMENT_GROWTH)); + var nextSegments = new List(initialSegmentCount); + foreach (ConfiguredNormalizationSegment segment in segments) + { + if (segment.IsProtectedMarker + || segment.Source.IndexOf( + target, + segment.StartIndex, + segment.Length, + StringComparison.Ordinal) < 0) + { + AppendConfiguredNormalizationSegment(nextSegments, segment); + continue; + } + + AppendConfiguredNormalizationReplacements( + nextSegments, + segment, + target, + configuredNormalizedValue); + } + + return nextSegments; + } + + private static void AppendConfiguredNormalizationReplacements( + List destination, + ConfiguredNormalizationSegment segment, + string target, + string configuredNormalizedValue) + { + // Preserve unmatched fragments for later rules and protect only generated replacement markers. + // 一致しない断片は後続規則向けに未保護で残し、生成した置換マーカーだけを保護します。 + int startIndex = segment.StartIndex; + int endIndex = checked(segment.StartIndex + segment.Length); + while (true) + { + int matchIndex = segment.Source.IndexOf( + target, + startIndex, + endIndex - startIndex, + StringComparison.Ordinal); + if (matchIndex < 0) + { + if (startIndex < endIndex) + { + AppendConfiguredNormalizationSegment(destination, new ConfiguredNormalizationSegment( + segment.Source, + StartIndex: startIndex, + Length: endIndex - startIndex, + IsProtectedMarker: false, + RepetitionCount: 1)); + } + break; + } + + if (matchIndex > startIndex) + { + AppendConfiguredNormalizationSegment(destination, new ConfiguredNormalizationSegment( + segment.Source, + StartIndex: startIndex, + Length: matchIndex - startIndex, + IsProtectedMarker: false, + RepetitionCount: 1)); + } + + AppendConfiguredNormalizationSegment(destination, new ConfiguredNormalizationSegment( + configuredNormalizedValue, + StartIndex: 0, + Length: configuredNormalizedValue.Length, + IsProtectedMarker: true, + RepetitionCount: 1)); + startIndex = checked(matchIndex + target.Length); + } + } + + private static void AppendConfiguredNormalizationSegment( + List destination, + ConfiguredNormalizationSegment segment) + { + // Coalesce adjacent raw slices and marker repeats so dense matches avoid per-match strings or segments. + // 密な一致で置換ごとの文字列やセグメントを作らないよう、隣接 raw slice とマーカー反復を結合します。 + if (segment.IsProtectedMarker && destination.Count > 0) + { + ConfiguredNormalizationSegment previous = destination[^1]; + if (previous.IsProtectedMarker + && string.Equals(previous.Source, segment.Source, StringComparison.Ordinal)) + { + destination[^1] = previous with + { + RepetitionCount = checked(previous.RepetitionCount + segment.RepetitionCount) + }; + return; + } + } + else if (!segment.IsProtectedMarker && destination.Count > 0) + { + ConfiguredNormalizationSegment previous = destination[^1]; + if (!previous.IsProtectedMarker + && ReferenceEquals(previous.Source, segment.Source) + && checked(previous.StartIndex + previous.Length) == segment.StartIndex) + { + destination[^1] = previous with + { + Length = checked(previous.Length + segment.Length) + }; + return; + } + } + + destination.Add(segment); + } + + private readonly record struct ConfiguredNormalizationSegment( + string Source, + int StartIndex, + int Length, + bool IsProtectedMarker, + int RepetitionCount); + + /// + /// Selects a deterministic configured-value marker that cannot already occur in either raw IL input. + /// raw IL のいずれにも存在しない、決定的な設定値正規化マーカーを選択します。 + /// + private static string CreateCollisionFreeConfiguredNormalizedValue(params IReadOnlyList[] lineCollections) + { + string markerStem = NORMALIZED_VALUE_PREFIX + "configured-value"; + var usedSuffixes = new HashSet(); + + // Collect every generated-form marker already present in the inputs before choosing the first unused form. + // 最初の未使用形式を選ぶ前に、入力中に既に存在する生成可能形式のマーカーをすべて収集します。 + foreach (IReadOnlyList lines in lineCollections) + { + foreach (string line in lines) + { + RecordUsedConfiguredMarkerSuffixes(line, markerStem, usedSuffixes); + } + } + + // Suffix zero represents the unsuffixed marker; generated numeric suffixes start at one. + // suffix 0 はsuffixなしのマーカーを表し、生成する数値suffixは1から始めます。 + if (!usedSuffixes.Contains(0)) + { + return CONFIGURED_NORMALIZED_VALUE; + } + + int suffix = 1; + while (usedSuffixes.Contains(suffix)) + { + suffix++; + } + return $"{markerStem}-{suffix}>"; + } + + private static void RecordUsedConfiguredMarkerSuffixes( + string line, + string markerStem, + HashSet usedSuffixes) + { + int searchIndex = 0; + while (searchIndex < line.Length) + { + int markerIndex = line.IndexOf(markerStem, searchIndex, StringComparison.Ordinal); + if (markerIndex < 0) + { + break; + } + + int suffixStart = markerIndex + markerStem.Length; + if (suffixStart < line.Length && line[suffixStart] == '>') + { + usedSuffixes.Add(0); + } + else if (suffixStart < line.Length && line[suffixStart] == '-') + { + TryRecordNumericSuffix(line, suffixStart + 1, usedSuffixes); + } + + searchIndex = suffixStart; + } + } + + private static void TryRecordNumericSuffix(string line, int digitStart, HashSet usedSuffixes) + { + // Accept only canonical positive suffixes because the selector never emits zero or leading zeros. + // 選択処理は0や先頭0付きsuffixを生成しないため、正規の正整数suffixだけを対象にします。 + if (digitStart >= line.Length + || line[digitStart] is < '1' or > '9') + { + return; + } + + int value = 0; + int index = digitStart; + while (index < line.Length && line[index] is >= '0' and <= '9') + { + int digit = line[index] - '0'; + if (value > (int.MaxValue - digit) / 10) + { + // Values outside Int32 cannot be emitted by the selector and therefore cannot collide. + // Int32範囲外の値は選択処理が生成できず、衝突しないため無視します。 + return; + } + + value = (value * 10) + digit; + index++; + } + + if (index < line.Length && line[index] == '>') + { + usedSuffixes.Add(value); + } + } + + private static string NormalizeSuffixAfterPrefix(string line, string prefix, string replacement) + { + ReadOnlySpan content = line.AsSpan().TrimStart(); + if (!content.StartsWith(prefix, StringComparison.Ordinal)) + { + return line; + } + + // Translate the trimmed offset back to the original line to preserve its indentation and prefix. + // trim後のoffsetを元の行へ戻し、インデントとprefixを保持します。 + int prefixIndex = line.Length - content.Length; + return string.Concat(line.AsSpan(0, prefixIndex + prefix.Length), replacement); } /// @@ -252,16 +956,114 @@ private static string ComputeBlockHash(List blockLines) } /// - /// Validates configured IL filter strings and returns warning messages for any that appear - /// too short or overly broad. Strings shorter than - /// characters risk matching legitimate IL lines. - /// 設定された IL フィルタ文字列を検証し、短すぎるまたは広範すぎるパターンに対する - /// 警告メッセージを返します。 文字未満の文字列は - /// 正規の IL 行を誤って除外するリスクがあります。 + /// Validates configured IL line-ignore strings and returns warning messages for strings + /// shorter than characters. + /// 設定された IL 行除外文字列を検証し、 文字未満の + /// 文字列に対する警告メッセージを返します。 /// - /// The normalized filter strings as returned by . / が返した正規化済みフィルタ文字列。 /// List of warning messages (empty if all strings are safe). / 警告メッセージのリスト(すべて安全なら空)。 - internal static List ValidateILFilterStrings(IReadOnlyCollection normalizedStrings) + internal static List ValidateILIgnoreContainingStrings(IReadOnlyList configuredStrings) + { + var effectiveStrings = ILConfiguredSubstringHelper.GetEffectiveIgnoreLineSubstrings(configuredStrings); + return ValidateMinimumLength( + effectiveStrings, + nameof(ConfigSettings.ILIgnoreLineContainingStrings), + "exclude legitimate IL lines"); + } + + /// + /// Validates configured IL normalization strings and returns warning messages for strings + /// shorter than characters. + /// 設定された IL 正規化文字列を検証し、 文字未満の + /// 文字列に対する警告メッセージを返します。 + /// + internal static List ValidateILNormalizeContainingStrings(IReadOnlyList configuredStrings) + { + var effectiveStrings = ILConfiguredSubstringHelper.GetEffectiveNormalizationSubstrings(configuredStrings); + var warnings = new List(); + int suppressedWarningCount = 0; + AppendMinimumLengthWarnings( + effectiveStrings, + nameof(ConfigSettings.ILNormalizeContainingStrings), + "normalize legitimate IL content", + warnings, + ref suppressedWarningCount); + + var configuredNonEmptyStrings = configuredStrings? + .Where(value => !string.IsNullOrWhiteSpace(value)) + .ToList() ?? new List(); + foreach (var duplicate in configuredNonEmptyStrings + .GroupBy(value => value, StringComparer.Ordinal) + .Where(group => group.Count() > 1) + .Select(group => group.Key)) + { + if (TryReserveNormalizationWarning(warnings, ref suppressedWarningCount)) + { + warnings.Add($"{nameof(ConfigSettings.ILNormalizeContainingStrings)}: \"{FormatConfiguredSubstringForWarning(duplicate)}\" is configured more than once. Duplicate entries are redundant; creator-profile values prepended before configured values are included in this validation."); + } + } + + for (int i = 0; i < effectiveStrings.Count; i++) + { + for (int j = i + 1; j < effectiveStrings.Count; j++) + { + string first = effectiveStrings[i]; + string second = effectiveStrings[j]; + if (first.Contains(second, StringComparison.Ordinal) || second.Contains(first, StringComparison.Ordinal)) + { + if (TryReserveNormalizationWarning(warnings, ref suppressedWarningCount)) + { + warnings.Add($"{nameof(ConfigSettings.ILNormalizeContainingStrings)}: \"{FormatConfiguredSubstringForWarning(first)}\" and \"{FormatConfiguredSubstringForWarning(second)}\" overlap by containment. Configured normalization uses sequential ordinal matching on unreplaced raw text in listed order; inserted markers are not reprocessed, but the result may still depend on rule order. Use non-overlapping values or verify the reported application order."); + } + } + } + } + + if (suppressedWarningCount > 0) + { + warnings.Add($"{nameof(ConfigSettings.ILNormalizeContainingStrings)}: showing the first {IL_NORMALIZATION_WARNING_DETAIL_LIMIT} safety warning details; {suppressedWarningCount} additional warnings were suppressed. Reduce short, duplicate, or overlapping values to review every safety warning."); + } + + return warnings; + } + + private static void AppendMinimumLengthWarnings( + IReadOnlyCollection normalizedStrings, + string settingName, + string riskDescription, + List warnings, + ref int suppressedWarningCount) + { + foreach (string value in normalizedStrings) + { + int unicodeCharacterCount = CountUnicodeCharacters(value); + if (unicodeCharacterCount < IL_FILTER_STRING_MIN_LENGTH) + { + if (TryReserveNormalizationWarning(warnings, ref suppressedWarningCount)) + { + warnings.Add($"{settingName}: \"{FormatConfiguredSubstringForWarning(value)}\" is very short ({unicodeCharacterCount} chars) and may inadvertently {riskDescription}. Consider using a more specific pattern."); + } + } + } + } + + private static bool TryReserveNormalizationWarning( + List warnings, + ref int suppressedWarningCount) + { + if (warnings.Count < IL_NORMALIZATION_WARNING_DETAIL_LIMIT) + { + return true; + } + + suppressedWarningCount++; + return false; + } + + private static List ValidateMinimumLength( + IReadOnlyCollection normalizedStrings, + string settingName, + string riskDescription) { var warnings = new List(); if (normalizedStrings == null || normalizedStrings.Count == 0) @@ -271,31 +1073,113 @@ internal static List ValidateILFilterStrings(IReadOnlyCollection foreach (var s in normalizedStrings) { - if (s.Length < IL_FILTER_STRING_MIN_LENGTH) + int unicodeCharacterCount = CountUnicodeCharacters(s); + if (unicodeCharacterCount < IL_FILTER_STRING_MIN_LENGTH) { - warnings.Add($"ILIgnoreLineContainingStrings: \"{s}\" is very short ({s.Length} chars) and may inadvertently exclude legitimate IL lines. Consider using a more specific pattern."); + warnings.Add($"{settingName}: \"{FormatConfiguredSubstringForWarning(s)}\" is very short ({unicodeCharacterCount} chars) and may inadvertently {riskDescription}. Consider using a more specific pattern."); } } return warnings; } - /// - /// Normalises the strings used for contains-based line exclusion during IL comparison (removes null/whitespace, trims, deduplicates). - /// IL 比較時に「含む」判定で除外対象とする文字列を正規化します(null/空白除外、trim、重複排除)。 - /// - private static List GetNormalizedIlIgnoreContainingStrings(IReadOnlyConfigSettings config) + private static int CountUnicodeCharacters(string value) { - if (config?.ILIgnoreLineContainingStrings == null) + int count = 0; + foreach (Rune _ in value.EnumerateRunes()) { - return new List(); + count++; } - return config.ILIgnoreLineContainingStrings - .Where(value => !string.IsNullOrWhiteSpace(value)) - .Select(value => value.Trim()) - .Distinct(StringComparer.Ordinal) - .ToList(); + return count; + } + + private static string FormatConfiguredSubstringForWarning(string value) + { + // Keep CommonMark punctuation inert and make invisible or ambiguous characters explicit. + // CommonMark 記号を無効化し、不可視または曖昧な文字を明示します。 + var encoded = new StringBuilder(value.Length); + int index = 0; + while (index < value.Length) + { + char codeUnit = value[index]; + if (char.IsHighSurrogate(codeUnit) + && index + 1 < value.Length + && char.IsLowSurrogate(value[index + 1])) + { + int scalarValue = char.ConvertToUtf32(codeUnit, value[index + 1]); + AppendRuneForConfiguredSubstringWarning(encoded, new Rune(scalarValue)); + index += 2; + continue; + } + + if (char.IsSurrogate(codeUnit)) + { + // Preserve an unpaired UTF-16 code unit instead of replacing it with U+FFFD. + // 対にならない UTF-16 code unit を U+FFFD に置き換えず、その値を保持します。 + AppendVisibleUnicodeEscapeForWarning(encoded, codeUnit); + } + else + { + AppendRuneForConfiguredSubstringWarning(encoded, new Rune(codeUnit)); + } + + index++; + } + + return encoded.ToString(); + } + + private static void AppendRuneForConfiguredSubstringWarning(StringBuilder encoded, Rune rune) + { + switch (rune.Value) + { + case '\\': + encoded.Append("\\"); + break; + case '\t': + encoded.Append("\t"); + break; + case '\r': + encoded.Append("\r"); + break; + case '\n': + encoded.Append("\n"); + break; + default: + UnicodeCategory category = Rune.GetUnicodeCategory(rune); + if (Rune.IsWhiteSpace(rune) + || category == UnicodeCategory.Control + || category == UnicodeCategory.Format + || category == UnicodeCategory.NonSpacingMark + || category == UnicodeCategory.SpacingCombiningMark + || category == UnicodeCategory.EnclosingMark) + { + AppendVisibleUnicodeEscapeForWarning(encoded, rune.Value); + } + else if (IsAsciiLetterOrDigitForWarning(rune.Value)) + { + encoded.Append((char)rune.Value); + } + else + { + encoded.Append("&#") + .Append(rune.Value.ToString(CultureInfo.InvariantCulture)) + .Append(';'); + } + break; + } + } + + private static bool IsAsciiLetterOrDigitForWarning(int value) => + (value >= 'A' && value <= 'Z') + || (value >= 'a' && value <= 'z') + || (value >= '0' && value <= '9'); + + private static void AppendVisibleUnicodeEscapeForWarning(StringBuilder destination, int value) + { + destination.Append(value <= 0xFFFF ? "\u" : "\U") + .Append(value.ToString(value <= 0xFFFF ? "X4" : "X8", CultureInfo.InvariantCulture)); } /// diff --git a/Services/ILOutputService.cs b/Services/ILOutputService.cs index 9a71333f..ca94392b 100644 --- a/Services/ILOutputService.cs +++ b/Services/ILOutputService.cs @@ -123,12 +123,12 @@ public async Task PrecomputeAsync(IEnumerable filesAbsolutePaths, int ma } /// - /// Disassembles old/new .NET assemblies with the same disassembler, applies exclusion lines (MVID, configured strings), and compares the IL. + /// Disassembles old/new .NET assemblies with the same disassembler, applies built-in normalization plus enabled configured normalization and line filtering, and compares the IL. /// Uses line-based streaming: reads process stdout line-by-line (avoids LOH allocations) and compares /// without materializing filtered line lists when IL text output is not required. /// Retries raw disassembly up to 5 attempts when one or both raw sides are empty, then fails if any raw side is still empty; filtered-empty results are logged without retrying. /// Outputs IL text files when is true. - /// old/new の .NET アセンブリを同一逆アセンブラで逆アセンブルし、MVID などの除外行を適用したうえで IL を比較します。 + /// old/new の .NET アセンブリを同一逆アセンブラで逆アセンブルし、組み込み正規化と、有効化された設定ベースの正規化・行フィルタを適用したうえで IL を比較します。 /// 行単位のストリーミング処理を使用: プロセスの stdout を行単位で読み取り(LOH 割り当てを回避)、 /// IL テキスト出力が不要な場合はフィルタ済み行リストを実体化せずに比較します。 /// raw 側の少なくとも片側が空の場合は最大 5 回まで再逆アセンブルし、それでも少なくとも片側が空なら失敗させます。フィルタ後に空になった場合はログのみで再試行しません。 @@ -139,10 +139,13 @@ public async Task PrecomputeAsync(IEnumerable filesAbsolutePaths, int ma string file1AbsolutePath = Path.Combine(oldFolderAbsolutePath, fileRelativePath); string file2AbsolutePath = Path.Combine(newFolderAbsolutePath, fileRelativePath); - var ilIgnoreContainingStrings = GetNormalizedIlIgnoreContainingStrings(_config); + var ilIgnoreContainingStrings = _config.ShouldIgnoreILLinesContainingConfiguredStrings + ? ILConfiguredSubstringHelper.GetEffectiveIgnoreLineSubstrings(_config.ILIgnoreLineContainingStrings) + : new List(); + var ilNormalizeContainingStrings = _config.ShouldILNormalizeContainingConfiguredStrings + ? ILConfiguredSubstringHelper.GetEffectiveNormalizationSubstrings(_config.ILNormalizeContainingStrings) + : new List(); bool shouldIgnore = _config.ShouldIgnoreILLinesContainingConfiguredStrings; - bool ignoreMVID = _config.ShouldIgnoreMVID; - for (int attemptNumber = 1; attemptNumber <= EMPTY_IL_LINE_SET_DISASSEMBLY_ATTEMPT_LIMIT; attemptNumber++) { cancellationToken.ThrowIfCancellationRequested(); @@ -171,7 +174,6 @@ public async Task PrecomputeAsync(IEnumerable filesAbsolutePaths, int ma shouldOutputIlText, shouldIgnore, ilIgnoreContainingStrings.Count, - ignoreMVID, attemptNumber, EMPTY_IL_LINE_SET_DISASSEMBLY_ATTEMPT_LIMIT, canRetryEmptyLineSet); @@ -184,17 +186,27 @@ public async Task PrecomputeAsync(IEnumerable filesAbsolutePaths, int ma $"{ERROR_EMPTY_RAW_IL_AFTER_RETRY_LIMIT} File='{fileRelativePath}', Old='{file1AbsolutePath}', New='{file2AbsolutePath}', RawOldLines={il1Lines.Count}, RawNewLines={il2Lines.Count}, Attempts={attemptNumber}."); } + string configuredNormalizedValue = ilNormalizeContainingStrings.Count == 0 + ? CONFIGURED_NORMALIZED_VALUE + : CreateCollisionFreeConfiguredNormalizedValue(il1Lines, il2Lines); + if (!shouldOutputIlText) { // Streaming comparison: filter and compare line-by-line without materializing filtered lists. // If lines differ, fall back to block-aware comparison to handle method reordering. // ストリーミング比較: フィルタ済み行リストを実体化せずに行単位でフィルタ・比較する。 // 行単位で不一致の場合、メソッド並び順変更を考慮しブロック単位比較にフォールバック。 - bool areILsEqual = StreamingFilteredSequenceEqual(il1Lines, il2Lines, shouldIgnore, ilIgnoreContainingStrings, ignoreMVID); + bool areILsEqual = StreamingFilteredSequenceEqual( + il1Lines, + il2Lines, + shouldIgnore, + ilIgnoreContainingStrings, + ilNormalizeContainingStrings, + configuredNormalizedValue); if (!areILsEqual) { - var filtered1 = FilterIlLines(il1Lines, shouldIgnore, ilIgnoreContainingStrings, ignoreMVID); - var filtered2 = FilterIlLines(il2Lines, shouldIgnore, ilIgnoreContainingStrings, ignoreMVID); + var filtered1 = FilterIlLines(il1Lines, shouldIgnore, ilIgnoreContainingStrings, ilNormalizeContainingStrings, configuredNormalizedValue); + var filtered2 = FilterIlLines(il2Lines, shouldIgnore, ilIgnoreContainingStrings, ilNormalizeContainingStrings, configuredNormalizedValue); LogEmptyFilteredLineSetIfNeeded( fileRelativePath, file1AbsolutePath, @@ -208,7 +220,6 @@ public async Task PrecomputeAsync(IEnumerable filesAbsolutePaths, int ma shouldOutputIlText, shouldIgnore, ilIgnoreContainingStrings.Count, - ignoreMVID, attemptNumber); areILsEqual = BlockAwareSequenceEqual(filtered1, filtered2); @@ -218,8 +229,8 @@ public async Task PrecomputeAsync(IEnumerable filesAbsolutePaths, int ma // Materialized path: need full filtered lists for IL text file output. // 実体化パス: IL テキストファイル出力用にフィルタ済み全行リストが必要。 - var il1LinesExcluded = FilterIlLines(il1Lines, shouldIgnore, ilIgnoreContainingStrings, ignoreMVID); - var il2LinesExcluded = FilterIlLines(il2Lines, shouldIgnore, ilIgnoreContainingStrings, ignoreMVID); + var filteredIl1Lines = FilterIlLines(il1Lines, shouldIgnore, ilIgnoreContainingStrings, ilNormalizeContainingStrings, configuredNormalizedValue); + var filteredIl2Lines = FilterIlLines(il2Lines, shouldIgnore, ilIgnoreContainingStrings, ilNormalizeContainingStrings, configuredNormalizedValue); LogEmptyFilteredLineSetIfNeeded( fileRelativePath, file1AbsolutePath, @@ -227,27 +238,26 @@ public async Task PrecomputeAsync(IEnumerable filesAbsolutePaths, int ma disassemblerLabel, il1Lines, il2Lines, - il1LinesExcluded.Count, - il2LinesExcluded.Count, + filteredIl1Lines.Count, + filteredIl2Lines.Count, rawLineSetIsEmpty, shouldOutputIlText, shouldIgnore, ilIgnoreContainingStrings.Count, - ignoreMVID, attemptNumber); - bool areEqual = il1LinesExcluded.SequenceEqual(il2LinesExcluded); + bool areEqual = filteredIl1Lines.SequenceEqual(filteredIl2Lines); if (!areEqual) { // Fall back to block-aware comparison to handle method/class reordering by the compiler. // コンパイラによるメソッド・クラスの並び替えを考慮し、ブロック単位比較にフォールバック。 - areEqual = BlockAwareSequenceEqual(il1LinesExcluded, il2LinesExcluded); + areEqual = BlockAwareSequenceEqual(filteredIl1Lines, filteredIl2Lines); } try { - // Save the exclusion-filtered IL text as *_IL.txt. - // 比較用に除外した IL テキストを *_IL.txt として保存する。 - await _ilTextOutputService.WriteFullIlTextsAsync(fileRelativePath, il1LinesExcluded, il2LinesExcluded); + // Save the filtered and normalized IL text as *_IL.txt. + // フィルタ・正規化済みの IL テキストを *_IL.txt として保存する。 + await _ilTextOutputService.WriteFullIlTextsAsync(fileRelativePath, filteredIl1Lines, filteredIl2Lines); } catch (Exception ex) when (ExceptionFilters.IsPathOrFileIoRecoverable(ex)) { @@ -277,7 +287,6 @@ private void LogEmptyFilteredLineSetIfNeeded( bool shouldOutputIlText, bool shouldIgnoreContainingStrings, int ignoreStringCount, - bool shouldIgnoreMvid, int attemptNumber) { bool filteredLineSetIsEmpty = !rawLineSetIsEmpty && (filteredOldLineCount == 0 || filteredNewLineCount == 0); @@ -300,7 +309,6 @@ private void LogEmptyFilteredLineSetIfNeeded( shouldOutputIlText, shouldIgnoreContainingStrings, ignoreStringCount, - shouldIgnoreMvid, attemptNumber, EMPTY_IL_LINE_SET_DISASSEMBLY_ATTEMPT_LIMIT, willRetry: false); @@ -320,7 +328,6 @@ private void LogIlLineSetDiagnostics( bool shouldOutputIlText, bool shouldIgnoreContainingStrings, int ignoreStringCount, - bool shouldIgnoreMvid, int attemptNumber, int maxAttemptCount, bool willRetry) @@ -328,9 +335,13 @@ private void LogIlLineSetDiagnostics( string filteredCounts = filteredOldLineCount.HasValue && filteredNewLineCount.HasValue ? $", FilteredOldLines={filteredOldLineCount.Value}, FilteredNewLines={filteredNewLineCount.Value}" : string.Empty; + // This diagnostic records settings that can cause an empty line set. Ignore rules can remove lines, + // whereas built-in and configured normalization only replace text and therefore cannot affect line counts. + // この診断には空の行セットを生じさせ得る設定だけを記録する。無視規則は行を削除する一方、 + // 組み込み・設定正規化は文字列を置換するだけで行数に影響しない。 _logger.LogMessage( logLevel, - $"IL comparison {stage} produced an empty line set for '{fileRelativePath}' (Old='{oldFileAbsolutePath}', New='{newFileAbsolutePath}', Disassembler='{disassemblerLabel ?? "(unknown)"}', RawOldLines={rawOldLineCount}, RawNewLines={rawNewLineCount}{filteredCounts}, ShouldOutputIlText={shouldOutputIlText}, IgnoreConfiguredStrings={shouldIgnoreContainingStrings}, IgnoreStringCount={ignoreStringCount}, IgnoreMVID={shouldIgnoreMvid}, Attempt={attemptNumber}/{maxAttemptCount}, WillRetry={willRetry}). Empty IL on one side can later appear as an inline diff skip with 0 vs N lines.", + $"IL comparison {stage} produced an empty line set for '{fileRelativePath}' (Old='{oldFileAbsolutePath}', New='{newFileAbsolutePath}', Disassembler='{disassemblerLabel ?? "(unknown)"}', RawOldLines={rawOldLineCount}, RawNewLines={rawNewLineCount}{filteredCounts}, ShouldOutputIlText={shouldOutputIlText}, IgnoreConfiguredStrings={shouldIgnoreContainingStrings}, IgnoreStringCount={ignoreStringCount}, Attempt={attemptNumber}/{maxAttemptCount}, WillRetry={willRetry}). Empty IL on one side can later appear as an inline diff skip with 0 vs N lines.", shouldOutputMessageToConsole: true); } diff --git a/Services/ReportGenerateService.Helpers.cs b/Services/ReportGenerateService.Helpers.cs index a1977f4d..38bb29ed 100644 --- a/Services/ReportGenerateService.Helpers.cs +++ b/Services/ReportGenerateService.Helpers.cs @@ -283,20 +283,6 @@ private static string BuildChangeTagDisplay(string fileRelativePath, FileDiffRes return string.Empty; } - private static List GetNormalizedIlIgnoreContainingStrings(IReadOnlyConfigSettings config) - { - if (config?.ILIgnoreLineContainingStrings == null) - { - return new List(); - } - - return config.ILIgnoreLineContainingStrings - .Where(value => !string.IsNullOrWhiteSpace(value)) - .Select(value => value.Trim()) - .Distinct(StringComparer.Ordinal) - .ToList(); - } - private static string FormatChecklistMarkdownCell(string item) => string.Join( "
    ", diff --git a/Services/ReportGenerateService.cs b/Services/ReportGenerateService.cs index eac5b7ff..f462a461 100644 --- a/Services/ReportGenerateService.cs +++ b/Services/ReportGenerateService.cs @@ -42,8 +42,6 @@ public ReportGenerateService(FileDiffResultLists fileDiffResultLists, ILoggerSer private const string REPORT_TITLE = "# Folder Diff Report"; private const string REPORT_DISASSEMBLER_NOT_USED = "N/A"; private const string REPORT_LIST_SEPARATOR = ", "; - private const string NOTE_MVID_SKIP = $"Note: When diffing {Constants.LABEL_IL}, lines starting with \"{Constants.IL_MVID_LINE_PREFIX}\" (if present) are ignored because they contain disassembler-emitted Module Version ID metadata that can change on rebuild without meaning the executable IL changed."; - private const string NOTE_IL_CONTAINS_SKIP_ENABLED_BUT_EMPTY = "Note: IL line-ignore-by-contains is enabled, but no non-empty strings are configured."; private const string REPORT_LEGEND_HEADER = "### Legend — Diff Detail"; private const string REPORT_IMPORTANCE_LEGEND_HEADER = "### Legend — Change Importance"; diff --git a/Services/ReportWriteContext.cs b/Services/ReportWriteContext.cs index fc105e74..c5a9f203 100644 --- a/Services/ReportWriteContext.cs +++ b/Services/ReportWriteContext.cs @@ -39,7 +39,10 @@ public sealed class ReportWriteContext /// Whether any timestamp regression warning was raised. / タイムスタンプ後退警告が発生したかどうか。 public bool HasTimestampRegressionWarning { get; init; } - /// Whether any IL filter string validation warning was raised. / IL フィルタ文字列検証警告が発生したかどうか。 + /// + /// Whether any configured IL substring safety warning was raised. + /// 設定された IL 部分文字列の安全性警告が発生したかどうか。 + /// public bool HasILFilterWarnings { get; init; } /// Optional IL cache instance for reporting cache statistics. / キャッシュ統計レポート用の IL キャッシュインスタンス(任意)。 diff --git a/Services/SectionWriters/HeaderSectionWriter.cs b/Services/SectionWriters/HeaderSectionWriter.cs index 13f16cb1..551067e7 100644 --- a/Services/SectionWriters/HeaderSectionWriter.cs +++ b/Services/SectionWriters/HeaderSectionWriter.cs @@ -1,5 +1,7 @@ using System; +using System.Globalization; using System.IO; +using System.Text; using FolderDiffIL4DotNet.Common; using FolderDiffIL4DotNet.Models; @@ -48,42 +50,120 @@ public void Write(StreamWriter writer, ReportWriteContext ctx) writer.WriteLine("|---------|-------|"); writer.WriteLine($"| Ignored Extensions | {string.Join(REPORT_LIST_SEPARATOR, ctx.Config.IgnoredExtensions)} |"); writer.WriteLine($"| Text File Extensions | {string.Join(REPORT_LIST_SEPARATOR, ctx.Config.TextFileExtensions)} |"); - if (ctx.Config.ShouldIgnoreILLinesContainingConfiguredStrings) + writer.WriteLine(); + writer.WriteLine("**Built-in IL Normalization** — Rules apply in the listed order to all IL text, preserving each matching prefix and replacing only its build-variant value."); + writer.WriteLine(); + writer.WriteLine("| Line Prefix Pattern | Replacement | Observed Output From |"); + writer.WriteLine("|----------------------|-------------|----------------------|"); + foreach (var rule in ILOutputService.BuiltInNormalizationRules) { - var ilIgnoreStrings = GetNormalizedIlIgnoreContainingStrings(ctx.Config); - if (ilIgnoreStrings.Count == 0) + writer.WriteLine($"| `{rule.Prefix}` | `{rule.Replacement.TrimStart()}` | `{rule.Disassembler}` |"); + } + writer.WriteLine(); + + var ilNormalizeStrings = ILConfiguredSubstringHelper.GetEffectiveNormalizationSubstrings(ctx.Config.ILNormalizeContainingStrings); + if (ctx.Config.ShouldILNormalizeContainingConfiguredStrings) + { + writer.WriteLine("**ILNormalizeContainingStrings** — Matches are replaced in the listed order with a comparison-local marker absent from both inputs; all other text remains comparable."); + writer.WriteLine(); + if (ilNormalizeStrings.Count == 0) + { + writer.WriteLine("Enabled, but no non-empty strings are configured."); + } + else { - writer.WriteLine("| IL Line Ignore | Enabled, but no non-empty strings are configured. |"); + writer.WriteLine("| Substring to Normalize (Escaped) |"); + writer.WriteLine("|----------------------------------|"); + foreach (var v in ilNormalizeStrings) + { + writer.WriteLine($"| {FormatConfiguredSubstringTableCell(v)} |"); + } } + writer.WriteLine(); } - writer.WriteLine(); + if (ctx.Config.ShouldIgnoreILLinesContainingConfiguredStrings) { - var ilIgnoreStrings = GetNormalizedIlIgnoreContainingStrings(ctx.Config); - if (ilIgnoreStrings.Count > 0) + var ilIgnoreStrings = ILConfiguredSubstringHelper.GetEffectiveIgnoreLineSubstrings(ctx.Config.ILIgnoreLineContainingStrings); + writer.WriteLine($"**ILIgnoreLineContainingStrings** — When diffing {Constants.LABEL_IL}, lines containing any of the configured strings are ignored:"); + writer.WriteLine(); + if (ilIgnoreStrings.Count == 0) + { + writer.WriteLine("Enabled, but no non-empty strings are configured."); + } + else { - writer.WriteLine($"**IL Ignored Strings** — When diffing {Constants.LABEL_IL}, lines containing any of the configured strings are ignored:"); - writer.WriteLine(); - writer.WriteLine("| Ignored String |"); - writer.WriteLine("|----------------|"); + writer.WriteLine("| Substring to Ignore (Escaped) |"); + writer.WriteLine("|-------------------------------|"); foreach (var v in ilIgnoreStrings) { - writer.WriteLine($"| \"{v}\" |"); + writer.WriteLine($"| {FormatConfiguredSubstringTableCell(v)} |"); } - writer.WriteLine(); } + writer.WriteLine(); } // (end of Configuration Details section) writer.WriteLine(); - // Notes — only show MVID note when MVID lines are actually ignored - // ノート — MVID 行が実際に除外される場合のみ MVID ノートを表示 - if (ctx.Config.ShouldIgnoreMVID) + } + + private static string FormatConfiguredSubstringTableCell(string value) + { + // Keep CommonMark punctuation inert, make invisible characters explicit, and double literal backslashes. + // CommonMark 記号を無効化し、不可視文字を明示して、実際の backslash を二重化します。 + var encoded = new StringBuilder(value.Length); + foreach (Rune rune in value.EnumerateRunes()) { - writer.WriteLine($"> {NOTE_MVID_SKIP}"); - writer.WriteLine(); + switch (rune.Value) + { + // Numeric references survive CommonMark parsing without becoming Markdown escapes. + // 数値文字参照は CommonMark 解析後に復号されるため、Markdown のエスケープとして消費されません。 + case '\\': + encoded.Append("\\"); + break; + case '\t': + encoded.Append("\t"); + break; + case '\r': + encoded.Append("\r"); + break; + case '\n': + encoded.Append("\n"); + break; + default: + UnicodeCategory category = Rune.GetUnicodeCategory(rune); + if (Rune.IsWhiteSpace(rune) + || category == UnicodeCategory.Control + || category == UnicodeCategory.Format) + { + AppendVisibleUnicodeEscape(encoded, rune.Value); + } + else if (IsAsciiLetterOrDigit(rune.Value)) + { + encoded.Append((char)rune.Value); + } + else + { + encoded.Append("&#") + .Append(rune.Value.ToString(CultureInfo.InvariantCulture)) + .Append(';'); + } + break; + } } + return encoded.ToString(); + } + + private static bool IsAsciiLetterOrDigit(int value) => + (value >= 'A' && value <= 'Z') + || (value >= 'a' && value <= 'z') + || (value >= '0' && value <= '9'); + + private static void AppendVisibleUnicodeEscape(StringBuilder destination, int value) + { + destination.Append(value <= 0xFFFF ? "\u" : "\U") + .Append(value.ToString(value <= 0xFFFF ? "X4" : "X8", CultureInfo.InvariantCulture)); } } } diff --git a/Services/SectionWriters/WarningsSectionWriter.cs b/Services/SectionWriters/WarningsSectionWriter.cs index bbeffd13..12160887 100644 --- a/Services/SectionWriters/WarningsSectionWriter.cs +++ b/Services/SectionWriters/WarningsSectionWriter.cs @@ -62,8 +62,8 @@ public void Write(StreamWriter writer, ReportWriteContext ctx) } } - // IL filter string validation warnings - // IL フィルタ文字列検証警告 + // Configured IL substring safety warnings + // 設定された IL 部分文字列の安全性警告 if (ctx.HasILFilterWarnings) { if (!wroteWarnings) @@ -73,10 +73,10 @@ public void Write(StreamWriter writer, ReportWriteContext ctx) } writer.WriteLine(); - var filterWarnings = ctx.FileDiffResultLists.ILFilterWarnings.OrderBy(w => w, StringComparer.Ordinal).ToList(); - writer.WriteLine($"### [ ! ] IL filter validation warnings ({filterWarnings.Count})"); + var ilSubstringWarnings = ctx.FileDiffResultLists.ILFilterWarnings.OrderBy(w => w, StringComparer.Ordinal).ToList(); + writer.WriteLine($"### [ ! ] IL substring configuration safety warnings ({ilSubstringWarnings.Count})"); writer.WriteLine(); - foreach (var warning in filterWarnings) + foreach (var warning in ilSubstringWarnings) { writer.WriteLine($"- {warning}"); } diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 1e802414..99b67c3e 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -155,7 +155,9 @@ The tool compares all files recursively. For .NET assemblies (`.dll`, `.exe`), i - macOS / Windows / Linux / Unix-like OS - IL disassembler (auto-probed per file) - Preferred: [`dotnet-ildasm`](https://www.nuget.org/packages/dotnet-ildasm/) or [`dotnet ildasm`](https://www.nuget.org/packages/dotnet-ildasm/) - - Fallback: [`ilspycmd`](https://www.nuget.org/packages/ilspycmd/) + - Pinned fallback baseline for inspecting assemblies targeting .NET 8, .NET 9, or .NET 10: [`ilspycmd 9.1.0.7988`](https://www.nuget.org/packages/ilspycmd/9.1.0.7988) + +`nildiff` itself targets `net8.0` and requires the .NET 8 runtime. The .NET 9 and .NET 10 references above describe target frameworks of assemblies being inspected, not runtimes used to execute `nildiff`. [.NET SDK 8.x](https://dotnet.microsoft.com/en-us/download/dotnet/8.0) installation examples: @@ -186,7 +188,7 @@ dotnet ildasm --version ``` ```bash -dotnet tool install --global ilspycmd +dotnet tool install --global ilspycmd --version 9.1.0.7988 # add $HOME/.dotnet/tools (macOS/Linux/Unix) or %USERPROFILE%\.dotnet\tools (Windows) to PATH if needed ``` @@ -226,8 +228,8 @@ Normal diff runs accept exactly two or three positional arguments. A fourth posi | `--clear-cache` | Interactive wizard to selectively delete IL cache files (by tool, version, or all). Read-only `.ilcache` files are unprotected automatically before deletion. | | `--skip-il` | Skip IL comparison for .NET assemblies entirely. | | `--no-timestamp-warnings` | Suppress timestamp-regression warnings. | -| `--creator` | Apply the default maintainer IL ignore profile (`buildserver-winforms`). Intended for the common `nildiff [label] --creator` flow. | -| `--creator-il-ignore-profile ` | Apply a maintainer-managed IL ignore profile and force [`ShouldIgnoreILLinesContainingConfiguredStrings`](#config-en-shouldignoreillinescontainingconfiguredstrings) to `true`. The profile strings are merged into [`ILIgnoreLineContainingStrings`](#config-en-ilignorelinecontainingstrings). Current built-in profile: `buildserver-winforms`. | +| `--creator` | Apply the default maintainer IL normalization profile (`creator-default`). This forcibly sets `ShouldILNormalizeContainingConfiguredStrings` to `true` and prepends the profile strings before the existing configured values in [`ILNormalizeContainingStrings`](#config-en-ilnormalizecontainingstrings). Intended for the common `nildiff [label] --creator` flow. | +| `--creator-il-ignore-profile ` | Apply a maintainer-managed IL normalization profile. The profile strings are prepended before the existing configured values in [`ILNormalizeContainingStrings`](#config-en-ilnormalizecontainingstrings); the legacy option name is retained for CLI compatibility. The built-in profile is `creator-default`. The removed name `buildserver-winforms` is rejected as an unknown profile. | | `--wizard` | Interactive mode: prompts for old folder, new folder, and an optional report label. Before the report-label prompt, it prints the existing report folder names under the active Reports root so you can avoid collisions or reuse part of an existing label. Press Enter on the report-label prompt to auto-generate a high-resolution timestamp label. Drag-and-drop friendly — auto-strips surrounding quotes, `file://` URI prefixes, backslash-escaped spaces, and percent-encoded characters. | | `--dry-run` | Enumerate files and show statistics without running comparison. | | `--fail-on-diff` | Opt in to CI gating: after all reports, audit logs, and other enabled artifacts are generated, return exit code `5` when final reportable Added/Removed/Modified entries remain. Differences removed by `IgnoredExtensions`, IL-noise suppression, or other comparison filters do not trigger code `5`. Without this flag, a completed comparison still returns `0` even when it reports differences. | @@ -491,7 +493,13 @@ For one matched pair, the decision order is: Important details: - `Added`, `Removed`, `Unchanged`, and `Modified` are decided by relative path, not by file name alone. -- If [`ShouldIgnoreILLinesContainingConfiguredStrings`](#config-en-shouldignoreillinescontainingconfiguredstrings) is `true`, lines containing any configured ignore string are also skipped during IL comparison. +- If [`ShouldIgnoreILLinesContainingConfiguredStrings`](#config-en-shouldignoreillinescontainingconfiguredstrings) is `true`, lines containing any configured ignore string are skipped during IL comparison. Matching is applied to every IL line without interpreting its instruction context. The `ldstr` instruction, which loads a program string literal onto the evaluation stack, is not an exception: a configured match inside its literal excludes the entire `ldstr` line. +- If [`ShouldILNormalizeContainingConfiguredStrings`](#config-en-shouldilnormalizecontainingconfiguredstrings) is `true`, matching portions configured in [`ILNormalizeContainingStrings`](#config-en-ilnormalizecontainingstrings) are replaced with a comparison-local collision-free marker before comparison. The marker is normally ``; if that text already occurs in either raw IL input, nildiff adds the first available numeric suffix such as `-1`. Replacement is applied to every IL line without interpreting its instruction context. The `ldstr` instruction, which loads a program string literal onto the evaluation stack, is not an exception: only the configured substring inside its literal is replaced, and the rest of the line remains comparable. MVID, RVA, code-size comments, and the WinForms `TypeLibraryTimeStampAttribute` value are normalized with rule-specific placeholders in every mode. For ilspycmd's multiline `TypeLibraryTimeStampAttribute` form, nildiff preserves the attribute header prefix and replaces the complete byte blob through its closing `)` with one stable marker. Every built-in pattern is tested against every IL text regardless of the disassembler used; the report's **Observed Output From** value records where that syntax was verified, not a condition that limits rule application. +- `ShouldIgnoreMVID` has been removed. If that key or the former `FOLDERDIFF_SHOULDIGNOREMVID` environment variable is specified, nildiff stops with a migration error instead of silently accepting it; remove the obsolete input. MVID values are always normalized. +- Built-in normalization rules are applied in the order shown in the report. The current rules are order-independent because their line-prefix patterns do not overlap: in particular, `// Code size ` and `// Code size: ` differ at the space/colon position. The current `creator-default` normalization substrings are also pairwise non-overlapping, so their order does not affect the result. +- Outside creator mode, effective `ILNormalizeContainingStrings` values are applied in their `config.json` array order. In creator mode, profile values are applied first in profile order, followed by the configured values in their `config.json` array order. The combined configured/profile list is limited to 256 entries of up to 4096 characters each; creator-profile and duplicate entries count toward the limit. The report table preserves the resulting application order. Empty values are omitted and exact duplicates appear only once in the effective table. Configured normalization uses sequential ordinal matching against portions of the raw line that have not already been replaced; inserted markers are protected from later rules. Across all configured rules, one IL line may produce at most 65,536 non-overlapping replacements and a normalized result of at most 4,194,304 UTF-16 code units; exceeding either limit stops the comparison with `InvalidDataException` before the expanded result is allocated. When one distinct value contains another, the result can depend on application order. When normalization is enabled, exact duplicates and containment relationships produce warnings in the console and report, including relationships that arise after creator-profile values are prepended to the configured values. At most 100 warning details are emitted, followed by a summary of any additional suppressed warnings; disabled normalization skips these relationship checks. +- Markdown configured-substring tables use reversible `(Escaped)` columns: each literal backslash appears as `\\`, tabs, carriage returns, and line feeds appear as `\t`, `\r`, and `\n`, and every other whitespace, control, or format character appears as `\uXXXX` or `\UXXXXXXXX` (for example, a space is `\u0020`). Markdown punctuation is isolated with numeric character references so values cannot become emphasis, links, images, or other inline syntax. These escapes affect report display only; matching still uses the exact configured string. The HTML report keeps its existing pre-wrapped display for ordinary whitespace but is not the canonical escaped representation. +- Configured-substring safety warnings render configured values in a single-line visible escape form for line breaks, other whitespace, control/format characters, combining marks, lone UTF-16 surrogates, and backslashes while keeping CommonMark punctuation inert, so configuration text cannot alter console/log or Markdown/HTML report structure. Matching still uses the original value. - If raw disassembly returns an empty line set on one or both sides, `nildiff` retries that old/new pair up to 5 attempts. If any raw side is still empty after the retry limit, the comparison fails with an error instead of writing empty `*_IL.txt` output. - If filtering removes all IL lines on one or both sides after raw disassembly succeeded, `nildiff` logs the empty filtered side but does not retry, because that can be a legitimate result of the configured ignore rules. - If IL comparison itself fails, the run stops instead of silently falling back to a weaker comparison. @@ -561,7 +569,9 @@ Override only the settings you want to change. For example: ```json { "ShouldIgnoreILLinesContainingConfiguredStrings": true, - "ILIgnoreLineContainingStrings": ["buildserver1_", "buildserver2_", "// Method begins at Relative Virtual Address (RVA) 0x", ".publickeytoken = ( ", ".custom instance void class [System.Windows.Forms]System.Windows.Forms.AxHost/TypeLibraryTimeStampAttribute::.ctor(string) = ( ", "// Code size "], + "ILIgnoreLineContainingStrings": ["custom generated comment"], + "ShouldILNormalizeContainingConfiguredStrings": true, + "ILNormalizeContainingStrings": ["buildserver1_", "buildserver2_"], "ShouldOutputFileTimestamps": false, "ShouldOutputILText": false, "ShouldIncludeIgnoredFiles": false, @@ -640,15 +650,20 @@ Override only the settings you want to change. For example: false Enables additional IL line-ignore filter by substring. - - ShouldIgnoreMVID - true - Whether to exclude MVID (Module Version ID) lines from IL comparison. Set to false to detect recompilation even when source code is identical. - ILIgnoreLineContainingStrings [] - String list used by IL substring-ignore filter. Strings shorter than 4 characters trigger a safety warning in both console output and reports, as they risk inadvertently excluding legitimate IL lines. + String list used by the IL substring-ignore filter across all IL instruction contexts, including string literals loaded by ldstr. A matching ldstr line is excluded in full. Strings shorter than 4 characters trigger a safety warning in both console output and reports, as they risk inadvertently excluding legitimate IL lines. + + + ShouldILNormalizeContainingConfiguredStrings + false + Enables configured substring normalization through ILNormalizeContainingStrings. Creator profiles set this to true. + + + ILNormalizeContainingStrings + [] + Strings whose matching portions are replaced with a comparison-local collision-free marker before IL comparison. The marker is normally <nildiff:normalized:configured-value>; when that text already occurs in either raw IL input, the first available numeric suffix is added. Replacement applies across all IL instruction contexts, including string literals loaded by ldstr. Leading and trailing whitespace in each non-empty value is preserved as part of the exact substring match; whitespace-only values are ignored. Unlike ILIgnoreLineContainingStrings, the surrounding line remains comparable. Outside creator mode, values are applied in array order. In creator mode, profile values are applied first in profile order, followed by configured values in array order; reports show that same combined application order. The combined configured/profile list is limited to 256 entries of up to 4096 characters each; creator-profile and duplicate entries count toward the limit. Ordinal matching processes only portions of the raw line that have not already been replaced, so inserted markers are not reprocessed by later values. The result can still depend on order when one distinct value contains another; exact duplicates are redundant and appear once in the effective application table. When normalization is enabled, duplicate or containment relationships, including those that arise after profile values are prepended to configured values, and strings shorter than 4 characters trigger safety warnings in both console output and reports. Warning output shows at most 100 details plus a suppressed-count summary. ShouldOutputFileTimestamps @@ -840,6 +855,7 @@ Any scalar (non-list) setting in [`config.json`](config.json) can be overridden export FOLDERDIFF_MAXPARALLELISM=4 export FOLDERDIFF_ENABLEILCACHE=false export FOLDERDIFF_SKIPIL=true +export FOLDERDIFF_SHOULDILNORMALIZECONTAININGCONFIGUREDSTRINGS=true export FOLDERDIFF_SHOULDGENERATEHTMLREPORT=false export FOLDERDIFF_ILCACHEDIRECTORYABSOLUTEPATH=/tmp/il-cache ``` @@ -853,11 +869,11 @@ export FOLDERDIFF_ILCACHEDIRECTORYABSOLUTEPATH=/tmp/il-cache Rules: - Environment variables are applied **after** [`config.json`](config.json) is loaded and **before** validation, so env-var values are subject to the same validation constraints as JSON values. - If an env var has an unrecognised value for its type (e.g. `"yes"` for a bool, `"x"` for an int), it is silently ignored and the JSON (or built-in default) value is kept. -- List properties ([`IgnoredExtensions`](#config-en-ignoredextensions), [`TextFileExtensions`](#config-en-textfileextensions), [`ILIgnoreLineContainingStrings`](#config-en-ilignorelinecontainingstrings), [`SpinnerFrames`](#config-en-spinnerframes)) cannot be overridden via environment variables; edit [`config.json`](config.json) for those. +- List properties ([`IgnoredExtensions`](#config-en-ignoredextensions), [`TextFileExtensions`](#config-en-textfileextensions), [`ILIgnoreLineContainingStrings`](#config-en-ilignorelinecontainingstrings), [`ILNormalizeContainingStrings`](#config-en-ilnormalizecontainingstrings), [`SpinnerFrames`](#config-en-spinnerframes)) cannot be overridden via environment variables; edit [`config.json`](config.json) for those. Notes: - Built-in defaults, including the full [`IgnoredExtensions`](#config-en-ignoredextensions) and [`TextFileExtensions`](#config-en-textfileextensions) lists, are defined in [`Models/ConfigSettings.cs`](Models/ConfigSettings.cs). -- For normal diff runs, after loading [`config.json`](config.json) and applying environment-variable plus runtime CLI overrides, if any effective value is out of range the run fails immediately with exit code `3` and an error message listing every invalid setting. Validated constraints: [`MaxLogGenerations`](#config-en-maxloggenerations) >= `1`; [`TextDiffParallelThresholdKilobytes`](#config-en-textdiffparallelthresholdkilobytes) >= `1`; [`TextDiffChunkSizeKilobytes`](#config-en-textdiffchunksizekilobytes) >= `1`; [`InlineDiffContextLines`](#config-en-inlinediffcontextlines) >= `0`; [`ILCacheMaxMemoryMegabytes`](#config-en-ilcachemaxmemorymegabytes) >= `0`; [`TextDiffChunkSizeKilobytes`](#config-en-textdiffchunksizekilobytes) must be less than [`TextDiffParallelThresholdKilobytes`](#config-en-textdiffparallelthresholdkilobytes); and [`SpinnerFrames`](#config-en-spinnerframes) must contain at least one element. +- For normal diff runs, after loading [`config.json`](config.json) and applying environment-variable plus runtime CLI overrides, if any effective value is out of range the run fails immediately with exit code `3` and an error message listing every invalid setting. Validated constraints: [`MaxLogGenerations`](#config-en-maxloggenerations) >= `1`; [`TextDiffParallelThresholdKilobytes`](#config-en-textdiffparallelthresholdkilobytes) >= `1`; [`TextDiffChunkSizeKilobytes`](#config-en-textdiffchunksizekilobytes) >= `1`; [`InlineDiffContextLines`](#config-en-inlinediffcontextlines) >= `0`; [`ILCacheMaxMemoryMegabytes`](#config-en-ilcachemaxmemorymegabytes) >= `0`; [`ILNormalizeContainingStrings`](#config-en-ilnormalizecontainingstrings) must contain at most `256` combined configured/profile entries of at most `4096` characters each; [`TextDiffChunkSizeKilobytes`](#config-en-textdiffchunksizekilobytes) must be less than [`TextDiffParallelThresholdKilobytes`](#config-en-textdiffparallelthresholdkilobytes); and [`SpinnerFrames`](#config-en-spinnerframes) must contain at least one element. - `--validate-config` validates [`config.json`](config.json) plus `FOLDERDIFF_*` environment-variable overrides before any runtime CLI overrides are applied. - `--print-config` prints the effective builder state after env-var and supported CLI overrides without semantic validation, so it can be used to inspect an otherwise invalid effective configuration. - Invalid CLI syntax for `--print-config` / `--validate-config` (unknown flags, bad `--threads` value, unknown `--creator-il-ignore-profile`) still returns exit code `2` before those commands run. @@ -1044,7 +1060,9 @@ dotnet run -- "/path/to/old-folder" "/path/to/new-folder" "my-comparison" --no-p - macOS / Windows / Linux / Unix 系 OS - IL 逆アセンブラ(ファイルごとに自動判定) - 優先: [`dotnet-ildasm`](https://www.nuget.org/packages/dotnet-ildasm/) または [`dotnet ildasm`](https://www.nuget.org/packages/dotnet-ildasm/) - - 代替: [`ilspycmd`](https://www.nuget.org/packages/ilspycmd/) + - .NET 8、.NET 9、.NET 10をtarget frameworkとするAssemblyを調査するための固定fallback baseline: [`ilspycmd 9.1.0.7988`](https://www.nuget.org/packages/ilspycmd/9.1.0.7988) + +`nildiff` 本体のtarget frameworkは `net8.0` で、実行には.NET 8 runtimeが必要です。上記の.NET 9と.NET 10は調査対象Assemblyのtarget frameworkを指し、`nildiff` 自体を実行するruntimeではありません。 [.NET SDK 8.x](https://dotnet.microsoft.com/ja-jp/download/dotnet/8.0) のインストール例: @@ -1077,7 +1095,7 @@ dotnet ildasm --version ``` ```bash -dotnet tool install --global ilspycmd +dotnet tool install --global ilspycmd --version 9.1.0.7988 # 必要に応じて PATH へ追加 # macOS/Linux/Unix: $HOME/.dotnet/tools # Windows: %USERPROFILE%\.dotnet\tools @@ -1119,8 +1137,8 @@ nildiff [reportLabel] [options] | `--clear-cache` | IL キャッシュファイルを選択的に削除する対話ウィザードを起動します(ツール別、バージョン別、全削除)。read-only 属性付きの `.ilcache` も削除前に属性解除して処理します。 | | `--skip-il` | .NET アセンブリの IL 比較をまるごとスキップします。 | | `--no-timestamp-warnings` | タイムスタンプ逆転警告を抑制します。 | -| `--creator` | 既定のメンテナー用 IL 無視プロファイル(`buildserver-winforms`)を適用します。想定する常用形は `nildiff [label] --creator` です。 | -| `--creator-il-ignore-profile ` | メンテナー管理の IL 無視プロファイルを適用し、[`ShouldIgnoreILLinesContainingConfiguredStrings`](#config-ja-shouldignoreillinescontainingconfiguredstrings) を `true` に強制します。プロファイル文字列は [`ILIgnoreLineContainingStrings`](#config-ja-ilignorelinecontainingstrings) へマージされます。組み込みプロファイルは現在 `buildserver-winforms` です。 | +| `--creator` | 既定のメンテナー用 IL 正規化プロファイル(`creator-default`)を適用します。`ShouldILNormalizeContainingConfiguredStrings` を強制的に `true` にし、プロファイル文字列を既存の [`ILNormalizeContainingStrings`](#config-ja-ilnormalizecontainingstrings) 設定値より前へ追加します。想定する常用形は `nildiff [label] --creator` です。 | +| `--creator-il-ignore-profile ` | メンテナー管理の IL 正規化プロファイルを適用します。プロファイル文字列は既存の [`ILNormalizeContainingStrings`](#config-ja-ilnormalizecontainingstrings) 設定値より前へ追加されます。CLI 互換性のため従来のオプション名を維持しています。組み込みプロファイルは `creator-default` です。廃止した旧名 `buildserver-winforms` は未知のプロファイルとして拒否します。 | | `--wizard` | 対話モード: 旧フォルダ、新フォルダ、任意のレポートラベルを対話入力で指定します。レポートラベル入力前に、現在の Reports ルート配下にある既存レポートフォルダ名を一覧表示するため、重複回避や既存ラベルの一部再利用がしやすくなります。レポートラベル入力は Enter だけで空欄確定でき、その場合は高粒度のタイムスタンプラベルを自動生成します。ドラッグ&ドロップ対応 — 囲みクォート、`file://` URI プレフィックス、バックスラッシュエスケープされたスペース、パーセントエンコード文字を自動除去します。 | | `--dry-run` | 比較を実行せずファイルを列挙し統計情報を表示します。 | | `--fail-on-diff` | CI ゲートを opt-in します。レポート、監査ログ、その他の有効な成果物をすべて生成した後、最終的な Added/Removed/Modified が残る場合に終了コード `5` を返します。`IgnoredExtensions`、IL ノイズ抑制、その他の比較フィルタで除外された差分はコード `5` の対象外です。このフラグがなければ、差分をレポートした正常な比較も従来どおり `0` を返します。 | @@ -1384,7 +1402,13 @@ flowchart TD 重要な点: - `Added` / `Removed` / `Unchanged` / `Modified` は、ファイル名だけでなく相対パスを基準に決まります。 -- [`ShouldIgnoreILLinesContainingConfiguredStrings`](#config-ja-shouldignoreillinescontainingconfiguredstrings) が `true` の場合は、設定した文字列を含む行も IL 比較から除外します。 +- [`ShouldIgnoreILLinesContainingConfiguredStrings`](#config-ja-shouldignoreillinescontainingconfiguredstrings) が `true` の場合は、設定した文字列を含む行を IL 比較から除外します。一致判定は命令の文脈を解釈せず、すべての IL 行へ適用します。プログラムの文字列リテラルを評価スタックへ読み込む命令である `ldstr` も例外ではなく、そのリテラル内で設定値が一致した場合は `ldstr` 行全体を除外します。 +- [`ShouldILNormalizeContainingConfiguredStrings`](#config-ja-shouldilnormalizecontainingconfiguredstrings) が `true` の場合は、[`ILNormalizeContainingStrings`](#config-ja-ilnormalizecontainingstrings) の一致部分を比較ペア内で衝突しないマーカーへ置換してから比較します。通常は `` を使い、この文字列が old/new いずれかの raw IL に既にある場合は `-1` など未使用の最初の数値 suffix を付けます。置換は命令の文脈を解釈せず、すべての IL 行へ適用します。プログラムの文字列リテラルを評価スタックへ読み込む命令である `ldstr` も例外ではなく、そのリテラル内では設定文字列に一致した部分だけを置換し、行の残りを比較対象に保持します。MVID、RVA、code-size コメント、WinForms の `TypeLibraryTimeStampAttribute` 値は全モードで規則別プレースホルダーへ正規化します。ilspycmd の複数行 `TypeLibraryTimeStampAttribute` 形式では、attribute header の接頭辞を保持し、閉じ `)` までのbyte blob全体を1個の安定したマーカーへ置換します。組み込みパターンは使用した逆アセンブラに関係なく、すべての IL text に対して全件評価されます。レポートの **Observed Output From** はその構文を確認した由来を示すだけで、規則の適用条件ではありません。 +- `ShouldIgnoreMVID` は廃止しました。このkeyまたは旧環境変数 `FOLDERDIFF_SHOULDIGNOREMVID` を指定すると、黙って受理せず移行エラーで停止します。廃止済みの指定を削除してください。MVID値は常に正規化します。 +- 組み込み正規化規則はレポートに表示された順で適用します。現行規則の行接頭辞パターンは互いに重ならないため、適用順は結果へ影響しません。特に `// Code size ` と `// Code size: ` は、空白とコロンの位置が異なる別パターンです。現行の `creator-default` 正規化文字列にも相互の包含関係がないため、その順序は結果へ影響しません。 +- creatorモードでない場合、実効 `ILNormalizeContainingStrings` は `config.json` の配列順で適用します。creatorモードでは、プロファイル値をプロファイル内の順序で先に適用し、その後に設定値を `config.json` の配列順で適用します。設定値とプロファイル値の結合後は最大256件、各値は最大4096文字で、プロファイル値と重複値も件数上限に含みます。レポートの表にも、この結合後の適用順で表示します。空の値は省略し、完全重複値は実効表へ1回だけ表示します。設定正規化は、まだ置換していないraw行部分だけをordinal一致で順番に処理し、挿入済みマーカーを後続規則の処理対象にしません。設定した全規則を合わせて、1 IL行で生成できる非重複置換は最大65,536件、正規化後の結果は最大4,194,304 UTF-16コード単位です。いずれかを超える場合は、展開後の結果を割り当てる前に `InvalidDataException` で比較を停止します。異なる設定値の一方が他方を含む場合は、それでも適用順によって結果が変わり得ます。正規化が有効な場合、完全重複または包含関係があると、creatorプロファイル値を設定値より前へ追加した結果生じた関係も含めて、コンソールとレポートに警告します。警告は最大100件の詳細と超過分の抑制件数を示す要約を出力します。正規化が無効な場合はこれらの関係検査を行いません。 +- Markdownレポートの設定部分文字列表では、可逆な `(Escaped)` 列を使います。実際のbackslashは1文字ごとに `\\`、tab、carriage return、line feedは `\t`、`\r`、`\n`、その他の空白・control・format文字は `\uXXXX` または `\UXXXXXXXX`(spaceなら `\u0020`)と表示します。Markdown記号は数値文字参照で分離し、値がemphasis、link、imageなどのinline構文にならないようにします。このescapeはレポート表示だけに適用し、一致判定では設定文字列をそのまま使います。HTMLレポートは通常の空白に対する既存のpre-wrap表示を維持しますが、正本となるescape表現ではありません。 +- 設定部分文字列の安全性警告では、改行、その他の空白、control/format文字、combining mark、単独UTF-16 surrogate、backslashを1行の可視escapeとして表示し、CommonMark記号を不活性化します。これにより、設定文字列がconsole/logやMarkdown/HTMLレポートの構造を変更できないようにします。一致判定には元の設定値をそのまま使います。 - フィルタ前の raw 逆アセンブル結果が old/new の少なくとも片側で空になった場合、`nildiff` は同じペアを最大 5 回まで再試行します。それでも少なくとも片側が空なら、空の `*_IL.txt` を書かずに error で比較を失敗させます。 - raw 逆アセンブルは成功し、その後のフィルタで少なくとも片側の IL 行がすべて除外された場合は、設定された無視ルールの正当な結果でありえるため、空のフィルタ後行セットをログに残しますが再試行はしません。 - IL 比較そのものに失敗した場合は、弱い比較へ黙って落とさず、その実行全体を停止します。 @@ -1454,7 +1478,9 @@ JSON Schema ファイル([`doc/config.schema.json`](doc/config.schema.json)) ```json { "ShouldIgnoreILLinesContainingConfiguredStrings": true, - "ILIgnoreLineContainingStrings": ["buildserver1_", "buildserver2_", "// Method begins at Relative Virtual Address (RVA) 0x", ".publickeytoken = ( ", ".custom instance void class [System.Windows.Forms]System.Windows.Forms.AxHost/TypeLibraryTimeStampAttribute::.ctor(string) = ( ", "// Code size "], + "ILIgnoreLineContainingStrings": ["custom generated comment"], + "ShouldILNormalizeContainingConfiguredStrings": true, + "ILNormalizeContainingStrings": ["buildserver1_", "buildserver2_"], "ShouldOutputFileTimestamps": false, "ShouldOutputILText": false, "ShouldIncludeIgnoredFiles": false, @@ -1533,15 +1559,20 @@ JSON Schema ファイル([`doc/config.schema.json`](doc/config.schema.json)) false IL 比較時の追加行除外(部分一致)を有効化するか。 - - ShouldIgnoreMVID - true - IL 比較から MVID(Module Version ID)行を除外するかどうか。false にするとソースコードが同一でも再コンパイルを検出できる。 - ILIgnoreLineContainingStrings [] - IL 行除外に使う文字列一覧。4 文字未満の文字列が含まれる場合、正規の IL 行を誤って除外するリスクがあるため、コンソールとレポートの両方に安全性警告が表示される。 + ldstr が読み込む文字列リテラルを含む、すべての IL 命令文脈で行除外に使う文字列一覧。ldstr 行で一致した場合も行全体を除外する。4 文字未満の文字列が含まれる場合、正規の IL 行を誤って除外するリスクがあるため、コンソールとレポートの両方に安全性警告が表示される。 + + + ShouldILNormalizeContainingConfiguredStrings + false + ILNormalizeContainingStrings による部分一致正規化を有効化する。creator プロファイル適用時は true になる。 + + + ILNormalizeContainingStrings + [] + IL 比較前に一致部分を比較ペア内で衝突しないマーカーへ置換する文字列一覧。通常は <nildiff:normalized:configured-value> を使い、この文字列が old/new いずれかの raw IL に既にある場合は未使用の最初の数値 suffix を付ける。置換は ldstr が読み込む文字列リテラルを含む、すべての IL 命令文脈へ適用する。空白だけの値は無視するが、空でない各値の前後空白は正確な部分一致条件の一部として保持する。ILIgnoreLineContainingStrings と異なり、行の周辺部分は比較対象として残る。creatorモードでない場合は配列順で逐次置換する。creatorモードでは、プロファイル値をプロファイル内の順序で先に適用し、その後に設定値を配列順で適用する。設定値とプロファイル値の結合後は最大256件、各値は最大4096文字で、プロファイル値と重複値も件数上限に含む。レポートにも同じ結合後の適用順で表示する。ordinal一致はまだ置換していないraw行部分だけを処理し、挿入済みマーカーを後続値で再処理しない。異なる値の一方が他方を含む場合は、それでも適用順によって結果が変わり得る。完全重複値は実効適用表に1回だけ表示する。正規化が有効な場合、4文字未満、完全重複、包含関係のある値には、creatorプロファイル値を設定値より前へ追加した結果生じた関係も含めて、コンソールとレポートの両方で安全性警告を表示する。警告出力は最大100件の詳細と超過分の抑制件数を示す要約に制限する。 ShouldOutputFileTimestamps @@ -1733,6 +1764,7 @@ JSON Schema ファイル([`doc/config.schema.json`](doc/config.schema.json)) export FOLDERDIFF_MAXPARALLELISM=4 export FOLDERDIFF_ENABLEILCACHE=false export FOLDERDIFF_SKIPIL=true +export FOLDERDIFF_SHOULDILNORMALIZECONTAININGCONFIGUREDSTRINGS=true export FOLDERDIFF_SHOULDGENERATEHTMLREPORT=false export FOLDERDIFF_ILCACHEDIRECTORYABSOLUTEPATH=/tmp/il-cache ``` @@ -1746,11 +1778,11 @@ export FOLDERDIFF_ILCACHEDIRECTORYABSOLUTEPATH=/tmp/il-cache ルール: - 環境変数は [`config.json`](config.json) 読み込み**後**・バリデーション**前**に適用されます。そのため、環境変数で設定した値も JSON と同じバリデーション制約の対象になります。 - 型に合わない値(bool に `"yes"`、int に `"x"` など)は警告なしで無視され、JSON(または組み込み既定値)が引き続き使用されます。 -- リスト型プロパティ([`IgnoredExtensions`](#config-ja-ignoredextensions)、[`TextFileExtensions`](#config-ja-textfileextensions)、[`ILIgnoreLineContainingStrings`](#config-ja-ilignorelinecontainingstrings)、[`SpinnerFrames`](#config-ja-spinnerframes))は環境変数でのオーバーライドに対応していません。これらは [`config.json`](config.json) を編集してください。 +- リスト型プロパティ([`IgnoredExtensions`](#config-ja-ignoredextensions)、[`TextFileExtensions`](#config-ja-textfileextensions)、[`ILIgnoreLineContainingStrings`](#config-ja-ilignorelinecontainingstrings)、[`ILNormalizeContainingStrings`](#config-ja-ilnormalizecontainingstrings)、[`SpinnerFrames`](#config-ja-spinnerframes))は環境変数でのオーバーライドに対応していません。これらは [`config.json`](config.json) を編集してください。 補足: - [`IgnoredExtensions`](#config-ja-ignoredextensions) と [`TextFileExtensions`](#config-ja-textfileextensions) を含む組み込み既定値の全体は [`Models/ConfigSettings.cs`](Models/ConfigSettings.cs) に定義しています。 -- 通常の diff 実行では、[`config.json`](config.json) の読み込み後、環境変数および実行時 CLI オーバーライドを適用した実効設定に範囲外の値がある場合は、終了コード `3` で即座に失敗し、全エラーを列挙したエラーメッセージを表示します。検証対象の制約: [`MaxLogGenerations`](#config-ja-maxloggenerations) >= `1`、[`TextDiffParallelThresholdKilobytes`](#config-ja-textdiffparallelthresholdkilobytes) >= `1`、[`TextDiffChunkSizeKilobytes`](#config-ja-textdiffchunksizekilobytes) >= `1`、[`InlineDiffContextLines`](#config-ja-inlinediffcontextlines) >= `0`、[`ILCacheMaxMemoryMegabytes`](#config-ja-ilcachemaxmemorymegabytes) >= `0`、[`TextDiffChunkSizeKilobytes`](#config-ja-textdiffchunksizekilobytes) は [`TextDiffParallelThresholdKilobytes`](#config-ja-textdiffparallelthresholdkilobytes) 未満であること、[`SpinnerFrames`](#config-ja-spinnerframes) は 1 件以上の要素を含むこと。 +- 通常の diff 実行では、[`config.json`](config.json) の読み込み後、環境変数および実行時 CLI オーバーライドを適用した実効設定に範囲外の値がある場合は、終了コード `3` で即座に失敗し、全エラーを列挙したエラーメッセージを表示します。検証対象の制約: [`MaxLogGenerations`](#config-ja-maxloggenerations) >= `1`、[`TextDiffParallelThresholdKilobytes`](#config-ja-textdiffparallelthresholdkilobytes) >= `1`、[`TextDiffChunkSizeKilobytes`](#config-ja-textdiffchunksizekilobytes) >= `1`、[`InlineDiffContextLines`](#config-ja-inlinediffcontextlines) >= `0`、[`ILCacheMaxMemoryMegabytes`](#config-ja-ilcachemaxmemorymegabytes) >= `0`、[`ILNormalizeContainingStrings`](#config-ja-ilnormalizecontainingstrings) は設定値とプロファイル値の結合後が最大 `256` 件かつ各値が最大 `4096` 文字であること、[`TextDiffChunkSizeKilobytes`](#config-ja-textdiffchunksizekilobytes) は [`TextDiffParallelThresholdKilobytes`](#config-ja-textdiffparallelthresholdkilobytes) 未満であること、[`SpinnerFrames`](#config-ja-spinnerframes) は 1 件以上の要素を含むこと。 - `--validate-config` は、[`config.json`](config.json) に `FOLDERDIFF_*` 環境変数オーバーライドを適用した状態を、実行時 CLI オーバーライド適用前に検証します。 - `--print-config` は、環境変数と対応 CLI オーバーライドを適用した builder 状態を、セマンティック検証なしでそのまま出力するため、範囲外を含む effective config の診断にも使えます。 - `--print-config` / `--validate-config` に対する CLI 構文エラー(未知フラグ、不正な `--threads` 値、未知の `--creator-il-ignore-profile`)は、各コマンド実行前に終了コード `2` で失敗します。 diff --git a/doc/DEVELOPER_GUIDE.md b/doc/DEVELOPER_GUIDE.md index 5d52aada..a54bd22c 100644 --- a/doc/DEVELOPER_GUIDE.md +++ b/doc/DEVELOPER_GUIDE.md @@ -33,7 +33,7 @@ Prerequisites: - [`.NET SDK 8.x`](https://dotnet.microsoft.com/en-us/download/dotnet/8.0) pinned via [`global.json`](../global.json) - One IL disassembler available on `PATH` - [`dotnet-ildasm`](https://www.nuget.org/packages/dotnet-ildasm/) or [`dotnet ildasm`](https://www.nuget.org/packages/dotnet-ildasm/) preferred - - [`ilspycmd`](https://www.nuget.org/packages/ilspycmd/) supported as fallback + - [`ilspycmd 9.1.0.7988`](https://www.nuget.org/packages/ilspycmd/9.1.0.7988) is the pinned fallback baseline for inspecting assemblies targeting .NET 8, .NET 9, or .NET 10; `nildiff` itself targets `net8.0` Common commands: @@ -457,7 +457,7 @@ Why this matters: | [`Services/IFileSystemService.cs`](../Services/IFileSystemService.cs) + [`Services/FileSystemService.cs`](../Services/FileSystemService.cs) | Discovery/output filesystem abstraction | Enables folder-level unit tests and lazy file discovery | | [`Services/FileDiffService.cs`](../Services/FileDiffService.cs) | Per-file decision tree | SHA256 -> IL -> text -> fallback | | [`Services/IFileComparisonService.cs`](../Services/IFileComparisonService.cs) + [`Services/FileComparisonService.cs`](../Services/FileComparisonService.cs) | Per-file compare/detect I/O abstraction | Enables file-level unit tests | -| [`Services/ILOutputService.cs`](../Services/ILOutputService.cs) | IL compare flow, line filtering, block-aware order-independent comparison, optional IL dump writing, IL filter string safety validation | Enforces same disassembler identity; falls back to block-level multiset comparison when line order differs; `ValidateILFilterStrings` warns on overly short filter strings (< 4 chars) | +| [`Services/ILOutputService.cs`](../Services/ILOutputService.cs) | IL compare flow, line filtering, block-aware order-independent comparison, optional IL dump writing, configured IL substring safety validation | Enforces same disassembler identity; falls back to block-level multiset comparison when line order differs; `ValidateILIgnoreContainingStrings` and `ValidateILNormalizeContainingStrings` validate configured patterns | | [`Services/ILOutput/ILBlockParser.cs`](../Services/ILOutput/ILBlockParser.cs) | Parses IL disassembly output into logical blocks (methods, classes, properties) | Used by `ILOutputService.BlockAwareSequenceEqual` for order-independent comparison | | [`Services/AssemblyMethodAnalyzer.cs`](../Services/AssemblyMethodAnalyzer.cs) | Method-level change detection via `System.Reflection.Metadata` | Best-effort; returns `null` on failure (optional `onError` callback reports exception details). Generic signatures are fully resolved with arity suffix stripping, nested type reference resolution, and `TypeSpecification` decoding for generic base types/interfaces. Detects type/method/property/field additions, removals, and modifications (access modifier changes, modifier changes, type changes, IL body changes). Each entry is auto-classified by [`ChangeImportanceClassifier`](../Services/ChangeImportanceClassifier.cs) | | [`Services/CompilerGeneratedResolver.cs`](../Services/CompilerGeneratedResolver.cs) | Annotates compiler-generated types/members with user-authored origins | Resolves async state machines, display classes, lambda methods, backing fields, local functions, record clone/synthesized members to human-readable descriptions; called as a post-processing step in `AssemblyMethodAnalyzer.Analyze` | @@ -543,7 +543,7 @@ flowchart TD Rules that are easy to break: - The first successful classification for a file is the final classification for that file. - IL comparison is only attempted after SHA256 mismatch and only for files detected as .NET executables. -- IL comparison ignores lines starting with [`Constants.IL_MVID_LINE_PREFIX`](../Common/Constants.cs) (`// MVID:`) unconditionally because they are disassembler-emitted Module Version ID metadata and can change on rebuild without reflecting an executable IL change. +- IL comparison always normalizes MVID, RVA, code-size, and WinForms type-library timestamp values because they can change on rebuild without reflecting an executable IL change. - Additional IL ignore rules are substring-based and case-sensitive (`StringComparison.Ordinal`). - IL comparison must use the same disassembler identity and version label for old/new. - Text comparison can fall back from chunk-parallel mode to sequential mode on error, but only because chunk-parallel exceptions are allowed to bubble to `FilesAreEqualAsync(...)`. @@ -555,10 +555,11 @@ Per-file mechanics: - [`DotNetDetector.DetectDotNetExecutable(...)`](../FolderDiffIL4DotNet.Core/Diagnostics/DotNetDetector.cs) distinguishes `NotDotNetExecutable` from `Failed`; [`FileDiffService`](../Services/FileDiffService.cs) logs a warning on `Failed` before skipping the IL path. - [`DotNetDisassemblerProvider`](../Services/DotNetDisassemblerProvider.cs) also treats recoverable managed-assembly detection failures and delegated disassembly failures as warning-level diagnostics, including provider display name plus file extension, then returns `false` / a failed `DisassemblyResult` so other providers or non-IL paths can continue. - Once SHA256 matches, the code records `SHA256Match` and returns immediately; no IL comparison or text comparison runs after that. -- The IL path delegates to [`ILOutputService.DiffDotNetAssembliesAsync(...)`](../Services/ILOutputService.cs), which disassembles old/new via `DisassemblePairWithSameDisassemblerAsync(...)`, normalizes the comparison label, filters lines, optionally writes filtered IL text, and returns both equality and the disassembler label. -- [`RealDisassemblerE2ETests`](../FolderDiffIL4DotNet.Tests/Services/RealDisassemblerE2ETests.cs) covers this boundary with the preferred tool path: it builds the same tiny class library twice with `Deterministic=false`, confirms the DLL bytes differ, and then verifies that [`dotnet-ildasm`](https://www.nuget.org/packages/dotnet-ildasm/) still returns `ILMatch` after filtering. +- The IL path delegates to [`ILOutputService.DiffDotNetAssembliesAsync(...)`](../Services/ILOutputService.cs), which disassembles old/new via `DisassemblePairAsLinesWithSameDisassemblerAsync(...)`, normalizes build-variant IL values, filters configured ignored lines, optionally writes normalized/filtered IL text, and returns both equality and the disassembler label. +- [`RealDisassemblerE2ETests`](../FolderDiffIL4DotNet.Tests/Services/RealDisassemblerE2ETests.cs) covers this boundary with the preferred tool path: it builds the same tiny class library twice with `Deterministic=false`, confirms the DLL bytes differ, and then verifies that [`dotnet-ildasm`](https://www.nuget.org/packages/dotnet-ildasm/) still returns `ILMatch` after normalization. - `BuildComparisonDisassemblerLabel(...)` is part of correctness. If old/new produce different tool identities or version labels, the code rejects that comparison and raises [`InvalidOperationException`](https://learn.microsoft.com/en-us/dotnet/api/system.invalidoperationexception?view=net-8.0). -- `ShouldExcludeIlLine(...)` always strips lines starting with [`Constants.IL_MVID_LINE_PREFIX`](../Common/Constants.cs) (`// MVID:`). If [`ShouldIgnoreILLinesContainingConfiguredStrings`](../Models/ConfigSettings.cs) is `true`, it also strips any substring from [`ILIgnoreLineContainingStrings`](../Models/ConfigSettings.cs) after trimming and deduplicating the configured values, using `StringComparison.Ordinal`. +- `ShouldExcludeIlLine(...)` strips configured substring matches only when [`ShouldIgnoreILLinesContainingConfiguredStrings`](../Models/ConfigSettings.cs) is `true`. It trims and deduplicates [`ILIgnoreLineContainingStrings`](../Models/ConfigSettings.cs), then uses `StringComparison.Ordinal`. +- `NormalizeIlLine(...)` always replaces single-line MVID, RVA, code-size, and WinForms `TypeLibraryTimeStampAttribute` values with stable rule-specific placeholders. The shared filtered-line reader additionally collapses ilspycmd's multiline type-library timestamp byte blob through its closing `)` into one marker, so streaming comparison and materialized IL output use the same normalized sequence. Detection prefixes are centralized in [`Constants`](../Common/Constants.cs). If [`ShouldILNormalizeContainingConfiguredStrings`](../Models/ConfigSettings.cs) is `true`, the old/new pair selects one comparison-local marker absent from both raw IL inputs (normally ``, otherwise the first free numeric suffix) and uses it in streaming comparison, fallback comparison, and materialized output. Each effective [`ILNormalizeContainingStrings`](../Models/ConfigSettings.cs) match is replaced with that marker while retaining the rest of the line. Non-empty normalization values preserve leading and trailing whitespace as part of the exact ordinal substring match; whitespace-only values are discarded. `MAX_CONFIGURED_NORMALIZATION_REPLACEMENTS_PER_LINE` limits all configured rules together to 65,536 non-overlapping replacements per line, and `MAX_CONFIGURED_NORMALIZED_LINE_UTF16_CODE_UNITS` limits the normalized line to 4,194,304 UTF-16 code units. Either excess throws `InvalidDataException` before allocating the expanded result. - Files that are not handled by IL comparison and whose extension is included in [`TextFileExtensions`](../Models/ConfigSettings.cs) are classified through the text path after the SHA256 check has already failed. With [`ShouldTreatTextByteDifferencesAsMismatch`](../Models/ConfigSettings.cs) at its default `true`, that SHA256 mismatch is reported as `TextMismatch` immediately so line-ending, BOM, and encoding-only byte differences remain visible to audit output. Setting it to `false` opts back into normalized decoded-line comparison. - When normalized text comparison is explicitly enabled, the code converts [`TextDiffParallelThresholdKilobytes`](../Models/ConfigSettings.cs) and [`TextDiffChunkSizeKilobytes`](../Models/ConfigSettings.cs) into effective byte counts and uses those values to choose the comparison method. If [`OptimizeForNetworkShares`](../Models/ConfigSettings.cs) is enabled, the code avoids chunk-parallel reads on remote storage and always uses sequential `DiffTextFilesAsync(...)`, regardless of file size. In local-optimized mode, it uses the old-side file size: below [`TextDiffParallelThresholdKilobytes`](../Models/ConfigSettings.cs) it stays sequential, and at or above the threshold it splits the file into fixed-size chunks based on [`TextDiffChunkSizeKilobytes`](../Models/ConfigSettings.cs) and runs `DiffTextFilesParallelAsync(...)`. - If [`TextDiffParallelMemoryLimitMegabytes`](../Models/ConfigSettings.cs) is greater than `0`, [`FileDiffService`](../Services/FileDiffService.cs) treats it as an additional buffer budget for chunk-parallel text diff, logs the current managed-heap size, and reduces the effective worker count or falls back to sequential comparison when that budget cannot cover the requested parallelism. @@ -640,7 +641,7 @@ The nested [`DiffSummaryStatistics`](../Models/FileDiffResultLists.cs) sealed re ## Configuration and Runtime Modes -[`ConfigSettings`](../Models/ConfigSettings.cs) is the single source of truth for defaults. [`config.json`](../config.json) is an override file, so omitted keys keep the defaults defined in code, and `null` collection/path values are normalized back to those defaults. When `--config` is omitted, [`ConfigService`](../Services/ConfigService.cs) resolves the effective file by checking the user-local app-data `config.json` first and the bundled `config.json` next to the executable second. After loading, the mutable [`ConfigSettingsBuilder`](../Models/ConfigSettingsBuilder.cs) already includes `FOLDERDIFF_*` environment-variable overrides. Normal diff runs then apply runtime CLI overrides and call [`ConfigSettingsBuilder.Validate()`](../Models/ConfigSettingsBuilder.cs) before [`ConfigSettingsBuilder.Build()`](../Models/ConfigSettingsBuilder.cs) produces the immutable runtime settings object. `--validate-config` validates that same builder before runtime CLI overrides are applied, while `--print-config` prints the builder after supported CLI overrides without semantic validation so diagnostically invalid effective configs can still be inspected. If semantic validation fails, the ProgramRunner config-build / validation step surfaces [`InvalidDataException`](https://learn.microsoft.com/en-us/dotnet/api/system.io.invaliddataexception?view=net-8.0) with a message that lists each invalid setting, and the run exits with code `3`. Validated constraints: [`MaxLogGenerations`](../Models/ConfigSettings.cs) >= `1`; [`TextDiffParallelThresholdKilobytes`](../Models/ConfigSettings.cs) >= `1`; [`TextDiffChunkSizeKilobytes`](../Models/ConfigSettings.cs) >= `1`; [`InlineDiffContextLines`](../Models/ConfigSettings.cs) >= `0`; [`ILCacheMaxMemoryMegabytes`](../Models/ConfigSettings.cs) >= `0`; [`TextDiffChunkSizeKilobytes`](../Models/ConfigSettings.cs) < [`TextDiffParallelThresholdKilobytes`](../Models/ConfigSettings.cs); and [`SpinnerFrames`](../Models/ConfigSettings.cs) must contain at least one element. For key-by-key descriptions, use the [user guide configuration table](../USER_GUIDE.md#readme-en-config). +[`ConfigSettings`](../Models/ConfigSettings.cs) is the single source of truth for defaults. [`config.json`](../config.json) is an override file, so omitted keys keep the defaults defined in code, and `null` collection/path values are normalized back to those defaults. When `--config` is omitted, [`ConfigService`](../Services/ConfigService.cs) resolves the effective file by checking the user-local app-data `config.json` first and the bundled `config.json` next to the executable second. After loading, the mutable [`ConfigSettingsBuilder`](../Models/ConfigSettingsBuilder.cs) already includes `FOLDERDIFF_*` environment-variable overrides. Normal diff runs then apply runtime CLI overrides and call [`ConfigSettingsBuilder.Validate()`](../Models/ConfigSettingsBuilder.cs) before [`ConfigSettingsBuilder.Build()`](../Models/ConfigSettingsBuilder.cs) produces the immutable runtime settings object. `--validate-config` validates that same builder before runtime CLI overrides are applied, while `--print-config` prints the builder after supported CLI overrides without semantic validation so diagnostically invalid effective configs can still be inspected. If semantic validation fails, the ProgramRunner config-build / validation step surfaces [`InvalidDataException`](https://learn.microsoft.com/en-us/dotnet/api/system.io.invaliddataexception?view=net-8.0) with a message that lists each invalid setting, and the run exits with code `3`. Validated constraints: [`MaxLogGenerations`](../Models/ConfigSettings.cs) >= `1`; [`TextDiffParallelThresholdKilobytes`](../Models/ConfigSettings.cs) >= `1`; [`TextDiffChunkSizeKilobytes`](../Models/ConfigSettings.cs) >= `1`; [`InlineDiffContextLines`](../Models/ConfigSettings.cs) >= `0`; [`ILCacheMaxMemoryMegabytes`](../Models/ConfigSettings.cs) >= `0`; [`TextDiffChunkSizeKilobytes`](../Models/ConfigSettings.cs) < [`TextDiffParallelThresholdKilobytes`](../Models/ConfigSettings.cs); [`ILNormalizeContainingStrings`](../Models/ConfigSettings.cs) contains at most [`ConfigSettings.MaxILNormalizeContainingStringsCount`](../Models/ConfigSettings.ILSettings.cs) combined configured/profile entries, each no longer than [`ConfigSettings.MaxILNormalizeContainingStringLength`](../Models/ConfigSettings.ILSettings.cs) Unicode characters; and [`SpinnerFrames`](../Models/ConfigSettings.cs) must contain at least one element. For key-by-key descriptions, use the [user guide configuration table](../USER_GUIDE.md#readme-en-config). **JSON syntax errors** (e.g. a trailing comma after the last property or array element — a common mistake) are caught by [`ConfigService`](../Services/ConfigService.cs) before validation runs. The error is logged to the run log file and printed to the console in red, including the line number and byte position from the underlying [`JsonException`](https://learn.microsoft.com/en-us/dotnet/api/system.text.json.jsonexception?view=net-8.0) and a trailing-comma hint. Standard JSON does not allow trailing commas: `"Key": "value",}` is invalid — remove the final comma. The run exits with code `3`. @@ -649,7 +650,7 @@ The nested [`DiffSummaryStatistics`](../Models/FileDiffResultLists.cs) sealed re | Group | Keys | Purpose | | --- | --- | --- | | Inclusion and report shape | [`IgnoredExtensions`](../Models/ConfigSettings.cs), [`TextFileExtensions`](../Models/ConfigSettings.cs), [`ShouldIncludeUnchangedFiles`](../Models/ConfigSettings.cs), [`ShouldIncludeIgnoredFiles`](../Models/ConfigSettings.cs), [`ShouldIncludeILCacheStatsInReport`](../Models/ConfigSettings.cs), [`ShouldOutputFileTimestamps`](../Models/ConfigSettings.cs), [`ShouldWarnWhenNewFileTimestampIsOlderThanOldFileTimestamp`](../Models/ConfigSettings.cs) | Controls scope, report verbosity, and timestamp-regression warnings. Note: [`ShouldOutputFileTimestamps`](../Models/ConfigSettings.cs) is purely supplementary — timestamps are never used in comparison logic; results (Unchanged / Modified / etc.) are determined solely by file content. | -| IL behavior | [`ShouldOutputILText`](../Models/ConfigSettings.cs), [`ShouldIgnoreILLinesContainingConfiguredStrings`](../Models/ConfigSettings.cs), [`ILIgnoreLineContainingStrings`](../Models/ConfigSettings.cs), [`SkipIL`](../Models/ConfigSettings.cs), [`DisassemblerBlacklistTtlMinutes`](../Models/ConfigSettings.cs) | Controls IL normalization, artifact output, and disassembler reliability (blacklist TTL) | +| IL behavior | [`ShouldOutputILText`](../Models/ConfigSettings.cs), [`ShouldIgnoreILLinesContainingConfiguredStrings`](../Models/ConfigSettings.cs), [`ILIgnoreLineContainingStrings`](../Models/ConfigSettings.cs), [`ShouldILNormalizeContainingConfiguredStrings`](../Models/ConfigSettings.cs), [`ILNormalizeContainingStrings`](../Models/ConfigSettings.cs), [`SkipIL`](../Models/ConfigSettings.cs), [`DisassemblerBlacklistTtlMinutes`](../Models/ConfigSettings.cs) | Controls IL normalization, artifact output, and disassembler reliability (blacklist TTL) | | Inline diff | [`EnableInlineDiff`](../Models/ConfigSettings.cs), [`InlineDiffContextLines`](../Models/ConfigSettings.cs), [`InlineDiffMaxDiffLines`](../Models/ConfigSettings.cs), [`InlineDiffMaxOutputLines`](../Models/ConfigSettings.cs), [`InlineDiffMaxEditDistance`](../Models/ConfigSettings.cs), [`InlineDiffLazyRender`](../Models/ConfigSettings.cs) | Controls inline diff rendering in the HTML report | | Parallelism | [`MaxParallelism`](../Models/ConfigSettings.cs), [`TextDiffParallelThresholdKilobytes`](../Models/ConfigSettings.cs), [`TextDiffChunkSizeKilobytes`](../Models/ConfigSettings.cs), [`TextDiffParallelMemoryLimitMegabytes`](../Models/ConfigSettings.cs) | Controls CPU usage, chunk sizing, and optional memory budget for large-text comparison | | Cache | [`EnableILCache`](../Models/ConfigSettings.cs), [`ILCacheDirectoryAbsolutePath`](../Models/ConfigSettings.cs), [`ILCacheStatsLogIntervalSeconds`](../Models/ConfigSettings.cs), [`ILCacheMaxDiskFileCount`](../Models/ConfigSettings.cs), [`ILCacheMaxDiskMegabytes`](../Models/ConfigSettings.cs), [`ILCacheMaxMemoryMegabytes`](../Models/ConfigSettings.cs), [`ILPrecomputeBatchSize`](../Models/ConfigSettings.cs) | Controls IL cache lifetime, storage, in-memory budget, and large-tree precompute batching | @@ -844,7 +845,7 @@ Some tests report as **Skipped** when run locally. This is intentional and does Which tests skip and why: - **[`DotNetDisassembleServiceTests`](../FolderDiffIL4DotNet.Tests/Services/DotNetDisassembleServiceTests.cs)** (six tests) — these exercise fallback and blacklist logic using fake `#!/bin/sh` shell scripts created by [`WriteExecutable`](../FolderDiffIL4DotNet.Tests/Services/DotNetDisassembleServiceTests.cs). [`File.SetUnixFileMode`](https://learn.microsoft.com/en-us/dotnet/api/system.io.file.setunixfilemode?view=net-8.0) and shell script execution are not available on Windows, so the tests call `Skip.If(OperatingSystem.IsWindows(), ...)` and report Skipped there. -- **[`RealDisassemblerE2ETests`](../FolderDiffIL4DotNet.Tests/Services/RealDisassemblerE2ETests.cs)** (one test) — this builds the same tiny class library twice with `Deterministic=false` and verifies that [`dotnet-ildasm`](https://www.nuget.org/packages/dotnet-ildasm/) produces `ILMatch` after MVID filtering. It still calls `Skip.IfNot(IsE2EEnabled(), ...)` for local opt-in behavior, but once `FOLDERDIFF_RUN_E2E=true` is set it asserts that a working [`dotnet-ildasm`](https://www.nuget.org/packages/dotnet-ildasm/) (or [`dotnet ildasm`](https://www.nuget.org/packages/dotnet-ildasm/)) is actually runnable, so missing tooling becomes a failure rather than a skip. +- **[`RealDisassemblerE2ETests`](../FolderDiffIL4DotNet.Tests/Services/RealDisassemblerE2ETests.cs)** (one test) — this builds the same tiny class library twice with `Deterministic=false` and verifies that [`dotnet-ildasm`](https://www.nuget.org/packages/dotnet-ildasm/) produces `ILMatch` after MVID normalization. It still calls `Skip.IfNot(IsE2EEnabled(), ...)` for local opt-in behavior, but once `FOLDERDIFF_RUN_E2E=true` is set it asserts that a working [`dotnet-ildasm`](https://www.nuget.org/packages/dotnet-ildasm/) (or [`dotnet ildasm`](https://www.nuget.org/packages/dotnet-ildasm/)) is actually runnable, so missing tooling becomes a failure rather than a skip. Why this is safe: - CI runs on both Linux (`build` job) and Windows (`test-windows` job), and both install a real [`dotnet-ildasm`](https://www.nuget.org/packages/dotnet-ildasm/), add the global tool directory to `PATH`, and set `FOLDERDIFF_RUN_E2E=true` before the test step. That ensures the preferred disassembler path, the real-disassembler E2E assertion, and Windows-specific code paths are all exercised in CI. A local Skipped result now means the explicit opt-in was not enabled; with the opt-in enabled, missing tooling fails the test. @@ -1016,7 +1017,7 @@ Some browsers (notably macOS Safari) ignore the `accept` attribute on ` IL -> text -> fallback` | | [`Services/IFileComparisonService.cs`](../Services/IFileComparisonService.cs) + [`Services/FileComparisonService.cs`](../Services/FileComparisonService.cs) | ファイル単位の比較/判定 I/O 抽象 | ファイル単位ユニットテスト向け | -| [`Services/ILOutputService.cs`](../Services/ILOutputService.cs) | IL 比較、行除外、ブロック単位順序非依存比較、任意 IL 出力、IL フィルタ文字列安全性検証 | 同一逆アセンブラ制約を保証;行順序が異なる場合はブロック単位マルチセット比較にフォールバック;`ValidateILFilterStrings` が短すぎるフィルタ文字列(4 文字未満)を警告 | +| [`Services/ILOutputService.cs`](../Services/ILOutputService.cs) | IL 比較、行除外、ブロック単位順序非依存比較、任意 IL 出力、IL フィルタ文字列安全性検証 | 同一逆アセンブラ制約を保証;行順序が異なる場合はブロック単位マルチセット比較にフォールバック;`ValidateILIgnoreContainingStrings` と `ValidateILNormalizeContainingStrings` が設定パターンを検証 | | [`Services/ILOutput/ILBlockParser.cs`](../Services/ILOutput/ILBlockParser.cs) | IL 逆アセンブリ出力を論理ブロック(メソッド、クラス、プロパティ)に分割 | `ILOutputService.BlockAwareSequenceEqual` で順序非依存比較に使用 | | [`Services/AssemblyMethodAnalyzer.cs`](../Services/AssemblyMethodAnalyzer.cs) | `System.Reflection.Metadata` によるメソッドレベル変更検出 | ベストエフォート;失敗時は `null` を返す(オプショナル `onError` コールバックで例外詳細を報告)。ジェネリクスシグネチャはアリティ接尾辞除去、ネスト型参照解決、ジェネリック基底型/インターフェースの `TypeSpecification` デコードにより完全解決。型・メソッド・プロパティ・フィールドの追加・削除・変更(アクセス修飾子変更、修飾子変更、型変更、IL ボディ変更)を検出。各エントリは [`ChangeImportanceClassifier`](../Services/ChangeImportanceClassifier.cs) により自動分類 | | [`Services/CompilerGeneratedResolver.cs`](../Services/CompilerGeneratedResolver.cs) | コンパイラ生成型/メンバーにユーザー記述元を注釈 | async ステートマシン、ディスプレイクラス、ラムダメソッド、バッキングフィールド、ローカル関数、record クローン/合成メンバーを人間可読な説明に解決。`AssemblyMethodAnalyzer.Analyze` の後処理ステップとして呼び出される | @@ -1525,7 +1526,7 @@ flowchart TD 壊しやすい前提: - 1 ファイルで最初に確定した分類がそのファイルの最終分類です。 - IL 比較は SHA256 不一致の後、かつ .NET 実行可能ファイルにのみ進みます。 -- IL 比較では [`Constants.IL_MVID_LINE_PREFIX`](../Common/Constants.cs)(`// MVID:`)で始まる行を常に無視します。これは逆アセンブラが出力する Module Version ID メタデータで、再ビルドのたびに変わり得るため、アセンブリの中身が実質的に同じでも、この行だけで差分ありと判定されてしまうことがあるためです。 +- IL 比較では MVID、RVA、code-size、WinForms のタイプライブラリタイムスタンプ値を常に正規化します。これらは再ビルドで変わり得ますが、実行 IL の意味ある差分を表さないためです。 - 追加の IL 行無視は部分一致で、大文字小文字を区別します(`StringComparison.Ordinal`)。 - old/new の IL 比較は、同じ逆アセンブラ識別子とバージョン表記でなければなりません。 - テキスト比較は、並列チャンク経路で例外が出た場合に逐次比較へフォールバックします。この挙動は、並列比較側で例外を握りつぶさず `FilesAreEqualAsync(...)` まで伝播させる前提で成り立っています。 @@ -1537,10 +1538,11 @@ flowchart TD - [`DotNetDetector.DetectDotNetExecutable(...)`](../FolderDiffIL4DotNet.Core/Diagnostics/DotNetDetector.cs) は `NotDotNetExecutable` と `Failed` を区別します。[`FileDiffService`](../Services/FileDiffService.cs) は `Failed` の場合に warning を出して IL 経路をスキップします。 - [`DotNetDisassemblerProvider`](../Services/DotNetDisassemblerProvider.cs) も、recoverable な managed-assembly 判定失敗や委譲先逆アセンブル失敗を provider 表示名とファイル拡張子付き warning として記録したうえで、`false` / 失敗 `DisassemblyResult` を返して他 provider や非 IL 経路へ処理を渡します。 - SHA256 が一致した時点で `SHA256Match` を記録して即終了し、その後に IL やテキスト比較へは進みません。 -- IL 経路は [`ILOutputService.DiffDotNetAssembliesAsync(...)`](../Services/ILOutputService.cs) に委譲され、内部で `DisassemblePairWithSameDisassemblerAsync(...)`、比較用ラベル正規化、行除外、任意の IL テキスト出力までをまとめて担当します。 -- [`RealDisassemblerE2ETests`](../FolderDiffIL4DotNet.Tests/Services/RealDisassemblerE2ETests.cs) では、この境界を推奨ツール経路付きで確認します。`Deterministic=false` の小さなクラスライブラリを 2 回ビルドして DLL バイト列が異なることを確認したうえで、[`dotnet-ildasm`](https://www.nuget.org/packages/dotnet-ildasm/) のフィルタ後 IL では `ILMatch` になることを検証します。 +- IL 経路は [`ILOutputService.DiffDotNetAssembliesAsync(...)`](../Services/ILOutputService.cs) に委譲され、内部で `DisassemblePairAsLinesWithSameDisassemblerAsync(...)`、ビルド依存 IL 値の正規化、設定された無視行の除外、任意の正規化/フィルタ済み IL テキスト出力までをまとめて担当します。 +- [`RealDisassemblerE2ETests`](../FolderDiffIL4DotNet.Tests/Services/RealDisassemblerE2ETests.cs) では、この境界を推奨ツール経路付きで確認します。`Deterministic=false` の小さなクラスライブラリを 2 回ビルドして DLL バイト列が異なることを確認したうえで、[`dotnet-ildasm`](https://www.nuget.org/packages/dotnet-ildasm/) の正規化後 IL では `ILMatch` になることを検証します。 - `BuildComparisonDisassemblerLabel(...)` は正しさの一部です。old/new でツール識別やバージョン表記がずれた場合は、その比較を認めず [`InvalidOperationException`](https://learn.microsoft.com/ja-jp/dotnet/api/system.invalidoperationexception?view=net-8.0) にします。 -- `ShouldExcludeIlLine(...)` は [`Constants.IL_MVID_LINE_PREFIX`](../Common/Constants.cs)(`// MVID:`)で始まる行を必ず除外します。さらに [`ShouldIgnoreILLinesContainingConfiguredStrings`](../Models/ConfigSettings.cs) が `true` の場合は、[`ILIgnoreLineContainingStrings`](../Models/ConfigSettings.cs) に設定された文字列を trim・重複排除したうえで、`StringComparison.Ordinal` の部分一致で除外します。 +- `ShouldExcludeIlLine(...)` は [`ShouldIgnoreILLinesContainingConfiguredStrings`](../Models/ConfigSettings.cs) が `true` の場合に限り、[`ILIgnoreLineContainingStrings`](../Models/ConfigSettings.cs) を trim・重複排除したうえで、`StringComparison.Ordinal` の部分一致で除外します。 +- `NormalizeIlLine(...)` は単一行の MVID、RVA、code-size、WinForms の `TypeLibraryTimeStampAttribute` 値を常に規則別の安定したプレースホルダーへ置換します。共通のフィルタ済み行readerは、ilspycmdの複数行type-library timestamp byte blobを閉じ `)` まで1個のマーカーへ縮約し、ストリーミング比較と実体化IL出力で同じ正規化済み行列を使います。検出用接頭辞は [`Constants`](../Common/Constants.cs) に集約しています。[`ShouldILNormalizeContainingConfiguredStrings`](../Models/ConfigSettings.cs) が `true` の場合は、old/newペアのraw IL双方に存在しない比較ローカルマーカー(通常は ``、衝突時は未使用の最初の数値suffix)を1個選び、ストリーミング比較、fallback比較、実体化出力で共有します。実効 [`ILNormalizeContainingStrings`](../Models/ConfigSettings.cs) の各一致部分をそのマーカーへ置換し、行の残りは比較対象として保持します。空でない正規化値の前後空白はordinalな正確部分一致の一部として保持し、空白だけの値は除外します。`MAX_CONFIGURED_NORMALIZATION_REPLACEMENTS_PER_LINE` は設定した全規則を合わせた1行の非重複置換を65,536件、`MAX_CONFIGURED_NORMALIZED_LINE_UTF16_CODE_UNITS` は正規化後の1行を4,194,304 UTF-16コード単位に制限します。いずれかを超えると、展開後の結果を割り当てる前に `InvalidDataException` をスローします。 - `.NET` 実行可能として IL 比較の対象にならず、かつ拡張子が [`TextFileExtensions`](../Models/ConfigSettings.cs) に含まれるファイルは、SHA256 比較が不一致になった後にテキスト経路で分類します。[`ShouldTreatTextByteDifferencesAsMismatch`](../Models/ConfigSettings.cs) が既定値 `true` の場合、その SHA256 不一致を即座に `TextMismatch` として報告するため、改行コード、BOM、エンコーディングのみのバイト差分も監査出力に残ります。`false` に設定すると、正規化されたデコード後行比較へ戻せます。 - 正規化テキスト比較を明示的に有効にした場合、[`TextDiffParallelThresholdKilobytes`](../Models/ConfigSettings.cs) と [`TextDiffChunkSizeKilobytes`](../Models/ConfigSettings.cs) を実効バイト数に変換し、比較方法を決めます。[`OptimizeForNetworkShares`](../Models/ConfigSettings.cs) が有効な場合は、ネットワーク共有上でチャンクごとに何度もファイルを開閉するコストを避けるため、ファイルサイズにかかわらず `DiffTextFilesAsync(...)` による逐次比較を使います。ローカル最適化時は old 側ファイルのサイズを基準にし、[`TextDiffParallelThresholdKilobytes`](../Models/ConfigSettings.cs) 未満なら逐次比較、以上なら [`TextDiffChunkSizeKilobytes`](../Models/ConfigSettings.cs) ごとの固定長チャンクに分割して `DiffTextFilesParallelAsync(...)` で並列比較します。 - [`TextDiffParallelMemoryLimitMegabytes`](../Models/ConfigSettings.cs) が `0` より大きい場合、[`FileDiffService`](../Services/FileDiffService.cs) はそれを並列テキスト比較で追加確保してよいバッファ予算として扱い、その時点の managed heap 使用量をログへ残しつつ、実効ワーカー数を下げるか逐次比較へフォールバックします。 @@ -1620,7 +1622,7 @@ catch (Exception ex) ## 設定と実行モード -既定値の正本は [`ConfigSettings`](../Models/ConfigSettings.cs) です。[`config.json`](../config.json) は override 用のファイルであり、省略したキーはコード既定値を維持します。`null` を与えたコレクションやキャッシュパスも既定値へ正規化されます。`--config` 未指定時、[`ConfigService`](../Services/ConfigService.cs) はまずユーザーローカル app-data の `config.json` を解決し、存在しない場合のみ実行ファイル横の同梱 `config.json` を使います。読み込み後のミュータブルな [`ConfigSettingsBuilder`](../Models/ConfigSettingsBuilder.cs) には、すでに `FOLDERDIFF_*` 環境変数オーバーライドが反映されています。通常の diff 実行では、その後に runtime CLI オーバーライドを適用し、[`ConfigSettingsBuilder.Validate()`](../Models/ConfigSettingsBuilder.cs) を通してから [`ConfigSettingsBuilder.Build()`](../Models/ConfigSettingsBuilder.cs) でイミュータブルな実行時設定を生成します。`--validate-config` はその同じビルダーを runtime CLI オーバーライド適用前に検証し、`--print-config` は対応 CLI オーバーライド適用後の builder 状態をセマンティック検証なしでそのまま表示します。制約違反があれば、ProgramRunner の設定 build / validation ステップが全エラーを列挙した [`InvalidDataException`](https://learn.microsoft.com/ja-jp/dotnet/api/system.io.invaliddataexception?view=net-8.0) を表面化させ、終了コード `3` で失敗します。検証対象の制約: [`MaxLogGenerations`](../Models/ConfigSettings.cs) >= `1`、[`TextDiffParallelThresholdKilobytes`](../Models/ConfigSettings.cs) >= `1`、[`TextDiffChunkSizeKilobytes`](../Models/ConfigSettings.cs) >= `1`、[`InlineDiffContextLines`](../Models/ConfigSettings.cs) >= `0`、[`ILCacheMaxMemoryMegabytes`](../Models/ConfigSettings.cs) >= `0`、[`TextDiffChunkSizeKilobytes`](../Models/ConfigSettings.cs) < [`TextDiffParallelThresholdKilobytes`](../Models/ConfigSettings.cs)、[`SpinnerFrames`](../Models/ConfigSettings.cs) は 1 件以上の要素を含むこと。キーごとの説明は [ユーザーガイドの設定表](../USER_GUIDE.md#readme-ja-config) を参照してください。 +既定値の正本は [`ConfigSettings`](../Models/ConfigSettings.cs) です。[`config.json`](../config.json) は override 用のファイルであり、省略したキーはコード既定値を維持します。`null` を与えたコレクションやキャッシュパスも既定値へ正規化されます。`--config` 未指定時、[`ConfigService`](../Services/ConfigService.cs) はまずユーザーローカル app-data の `config.json` を解決し、存在しない場合のみ実行ファイル横の同梱 `config.json` を使います。読み込み後のミュータブルな [`ConfigSettingsBuilder`](../Models/ConfigSettingsBuilder.cs) には、すでに `FOLDERDIFF_*` 環境変数オーバーライドが反映されています。通常の diff 実行では、その後に runtime CLI オーバーライドを適用し、[`ConfigSettingsBuilder.Validate()`](../Models/ConfigSettingsBuilder.cs) を通してから [`ConfigSettingsBuilder.Build()`](../Models/ConfigSettingsBuilder.cs) でイミュータブルな実行時設定を生成します。`--validate-config` はその同じビルダーを runtime CLI オーバーライド適用前に検証し、`--print-config` は対応 CLI オーバーライド適用後の builder 状態をセマンティック検証なしでそのまま表示します。制約違反があれば、ProgramRunner の設定 build / validation ステップが全エラーを列挙した [`InvalidDataException`](https://learn.microsoft.com/ja-jp/dotnet/api/system.io.invaliddataexception?view=net-8.0) を表面化させ、終了コード `3` で失敗します。検証対象の制約: [`MaxLogGenerations`](../Models/ConfigSettings.cs) >= `1`、[`TextDiffParallelThresholdKilobytes`](../Models/ConfigSettings.cs) >= `1`、[`TextDiffChunkSizeKilobytes`](../Models/ConfigSettings.cs) >= `1`、[`InlineDiffContextLines`](../Models/ConfigSettings.cs) >= `0`、[`ILCacheMaxMemoryMegabytes`](../Models/ConfigSettings.cs) >= `0`、[`TextDiffChunkSizeKilobytes`](../Models/ConfigSettings.cs) < [`TextDiffParallelThresholdKilobytes`](../Models/ConfigSettings.cs)、[`ILNormalizeContainingStrings`](../Models/ConfigSettings.cs) は設定値とプロファイル値の結合後に最大 [`ConfigSettings.MaxILNormalizeContainingStringsCount`](../Models/ConfigSettings.ILSettings.cs) 件で、各値は最大 [`ConfigSettings.MaxILNormalizeContainingStringLength`](../Models/ConfigSettings.ILSettings.cs) Unicode文字、[`SpinnerFrames`](../Models/ConfigSettings.cs) は 1 件以上の要素を含むこと。キーごとの説明は [ユーザーガイドの設定表](../USER_GUIDE.md#readme-ja-config) を参照してください。 **JSON 書式エラー**(最後のプロパティや配列要素の後のトレイリングカンマなど、よくあるミス)はバリデーション実行前に [`ConfigService`](../Services/ConfigService.cs) が検出します。エラーは実行ログへ書き込まれ、コンソールには赤字で行番号・バイト位置(内部の [`JsonException`](https://learn.microsoft.com/ja-jp/dotnet/api/system.text.json.jsonexception?view=net-8.0) から取得)とトレイリングカンマへのヒントを表示します。標準 JSON はトレイリングカンマを許容しないため、`"Key": "value",}` のように末尾にカンマがある場合は削除してください。終了コードは `3` です。 @@ -1629,7 +1631,7 @@ catch (Exception ex) | グループ | 主なキー | 目的 | | --- | --- | --- | | 対象範囲とレポート形状 | [`IgnoredExtensions`](../Models/ConfigSettings.cs), [`TextFileExtensions`](../Models/ConfigSettings.cs), [`ShouldIncludeUnchangedFiles`](../Models/ConfigSettings.cs), [`ShouldIncludeIgnoredFiles`](../Models/ConfigSettings.cs), [`ShouldIncludeILCacheStatsInReport`](../Models/ConfigSettings.cs), [`ShouldOutputFileTimestamps`](../Models/ConfigSettings.cs), [`ShouldWarnWhenNewFileTimestampIsOlderThanOldFileTimestamp`](../Models/ConfigSettings.cs) | 比較対象、レポート粒度、更新日時逆転警告の制御。[`ShouldOutputFileTimestamps`](../Models/ConfigSettings.cs) は純粋な補助情報であり、更新日時は比較ロジックには一切使用しない。Unchanged / Modified 等の判定はファイル内容のみで行われる。 | -| IL 関連 | [`ShouldOutputILText`](../Models/ConfigSettings.cs), [`ShouldIgnoreILLinesContainingConfiguredStrings`](../Models/ConfigSettings.cs), [`ILIgnoreLineContainingStrings`](../Models/ConfigSettings.cs), [`SkipIL`](../Models/ConfigSettings.cs), [`DisassemblerBlacklistTtlMinutes`](../Models/ConfigSettings.cs) | IL 正規化・成果物出力・逆アセンブラ信頼性(ブラックリスト TTL)の制御 | +| IL 関連 | [`ShouldOutputILText`](../Models/ConfigSettings.cs), [`ShouldIgnoreILLinesContainingConfiguredStrings`](../Models/ConfigSettings.cs), [`ILIgnoreLineContainingStrings`](../Models/ConfigSettings.cs), [`ShouldILNormalizeContainingConfiguredStrings`](../Models/ConfigSettings.cs), [`ILNormalizeContainingStrings`](../Models/ConfigSettings.cs), [`SkipIL`](../Models/ConfigSettings.cs), [`DisassemblerBlacklistTtlMinutes`](../Models/ConfigSettings.cs) | IL 正規化・成果物出力・逆アセンブラ信頼性(ブラックリスト TTL)の制御 | | インライン差分 | [`EnableInlineDiff`](../Models/ConfigSettings.cs), [`InlineDiffContextLines`](../Models/ConfigSettings.cs), [`InlineDiffMaxDiffLines`](../Models/ConfigSettings.cs), [`InlineDiffMaxOutputLines`](../Models/ConfigSettings.cs), [`InlineDiffMaxEditDistance`](../Models/ConfigSettings.cs), [`InlineDiffLazyRender`](../Models/ConfigSettings.cs) | HTML レポートでのインライン差分表示を制御 | | 並列度 | [`MaxParallelism`](../Models/ConfigSettings.cs), [`TextDiffParallelThresholdKilobytes`](../Models/ConfigSettings.cs), [`TextDiffChunkSizeKilobytes`](../Models/ConfigSettings.cs), [`TextDiffParallelMemoryLimitMegabytes`](../Models/ConfigSettings.cs) | CPU 利用、チャンク粒度、大きいテキスト比較時の任意メモリ予算を制御 | | キャッシュ | [`EnableILCache`](../Models/ConfigSettings.cs), [`ILCacheDirectoryAbsolutePath`](../Models/ConfigSettings.cs), [`ILCacheStatsLogIntervalSeconds`](../Models/ConfigSettings.cs), [`ILCacheMaxDiskFileCount`](../Models/ConfigSettings.cs), [`ILCacheMaxDiskMegabytes`](../Models/ConfigSettings.cs), [`ILCacheMaxMemoryMegabytes`](../Models/ConfigSettings.cs), [`ILPrecomputeBatchSize`](../Models/ConfigSettings.cs) | IL キャッシュの寿命、保存先、メモリ予算、大規模ツリー向け事前計算バッチを制御 | @@ -1824,7 +1826,7 @@ v* タグ push 時: スキップされるテストとその理由: - **[`DotNetDisassembleServiceTests`](../FolderDiffIL4DotNet.Tests/Services/DotNetDisassembleServiceTests.cs)**(6 件)— 偽の `#!/bin/sh` シェルスクリプトを [`WriteExecutable`](../FolderDiffIL4DotNet.Tests/Services/DotNetDisassembleServiceTests.cs) で生成し、フォールバック・ブラックリスト挙動を決定的に検証します。[`File.SetUnixFileMode`](https://learn.microsoft.com/ja-jp/dotnet/api/system.io.file.setunixfilemode?view=net-8.0) およびシェルスクリプトの実行は Windows では使えないため、`Skip.If(OperatingSystem.IsWindows(), ...)` を呼び出して Skipped を報告します。 -- **[`RealDisassemblerE2ETests`](../FolderDiffIL4DotNet.Tests/Services/RealDisassemblerE2ETests.cs)**(1 件)— `Deterministic=false` で 2 回ビルドした同一クラスライブラリを [`dotnet-ildasm`](https://www.nuget.org/packages/dotnet-ildasm/) が MVID 除外後に `ILMatch` と判定することを確認します。ローカルでは `Skip.IfNot(IsE2EEnabled(), ...)` による opt-in のままですが、`FOLDERDIFF_RUN_E2E=true` が設定されたら、利用可能な [`dotnet-ildasm`](https://www.nuget.org/packages/dotnet-ildasm/)(または [`dotnet ildasm`](https://www.nuget.org/packages/dotnet-ildasm/))が実際に実行できることを assert するため、ツール不足はスキップではなく失敗になります。 +- **[`RealDisassemblerE2ETests`](../FolderDiffIL4DotNet.Tests/Services/RealDisassemblerE2ETests.cs)**(1 件)— `Deterministic=false` で 2 回ビルドした同一クラスライブラリを [`dotnet-ildasm`](https://www.nuget.org/packages/dotnet-ildasm/) が MVID 正規化後に `ILMatch` と判定することを確認します。ローカルでは `Skip.IfNot(IsE2EEnabled(), ...)` による opt-in のままですが、`FOLDERDIFF_RUN_E2E=true` が設定されたら、利用可能な [`dotnet-ildasm`](https://www.nuget.org/packages/dotnet-ildasm/)(または [`dotnet ildasm`](https://www.nuget.org/packages/dotnet-ildasm/))が実際に実行できることを assert するため、ツール不足はスキップではなく失敗になります。 なぜ安全か: - CI は Linux(`build` ジョブ)と Windows(`test-windows` ジョブ)の両方で動き、どちらもテストステップの前に実 [`dotnet-ildasm`](https://www.nuget.org/packages/dotnet-ildasm/) をインストールし、グローバルツールディレクトリを `PATH` に追加し、`FOLDERDIFF_RUN_E2E=true` を設定します。これにより、優先逆アセンブラ経路、実逆アセンブラ E2E アサーション、Windows 固有コードパスが CI 上で検証されます。ローカルの Skipped は明示的な opt-in が無効なことを示し、opt-in 済みでのツール不足は失敗として扱われます。 diff --git a/doc/TESTING_GUIDE.md b/doc/TESTING_GUIDE.md index ead39c04..b075429a 100644 --- a/doc/TESTING_GUIDE.md +++ b/doc/TESTING_GUIDE.md @@ -36,8 +36,9 @@ Version metadata regression coverage verifies that `SystemInfo` separates the th | Entry and configuration | [`ProgramTests`](../FolderDiffIL4DotNet.Tests/ProgramTests.cs), [`ProgramRunnerTests`](../FolderDiffIL4DotNet.Tests/ProgramRunnerTests.cs), [`CliOptionsTests`](../FolderDiffIL4DotNet.Tests/CliOptionsTests.cs), [`ConfigServiceTests`](../FolderDiffIL4DotNet.Tests/Services/ConfigServiceTests.cs), [`ConfigSettingsTests`](../FolderDiffIL4DotNet.Tests/Models/ConfigSettingsTests.cs) | `Main` exit codes, typed [`ProgramRunner`](../ProgramRunner.cs) exit-code mapping for invalid arguments vs. config failures, phase ordering around validation vs. config loading, minimal end-to-end execution, CLI argument parsing via [`CliParser`](../Runner/CliParser.cs) (null/empty/positional-only args, every documented flag plus hidden spinner Easter-egg compatibility flags, unknown flag detection, combined flags, `--config`/`--threads` missing-value errors, spinner last-wins behavior, multiple-spinner detection (`MultipleSpinnersDetected`)), code-defined config defaults (verified via `ConfigSettings.Default*` named constants) and override behavior, SHA256/timestamp warning console and report output, reflection-backed verification of internal IL cache defaults wired by [`ProgramRunner`](../ProgramRunner.cs), completion summary chart output (bar rendering for each file category, zero-total skip, label alignment); JSON syntax error reporting: trailing comma in object (`{"Key":"v",}`), trailing comma in array (`["a","b",]`), and multiline JSON with line-number verification in the [`InvalidDataException`](https://learn.microsoft.com/en-us/dotnet/api/system.io.invaliddataexception?view=net-8.0) message, `line N, position N` pattern verification, trailing-comma hint appended to all JSON parse errors, custom config path `FileNotFoundException` with path in message; preflight write-permission check with fail-fast on [`IOException`](https://learn.microsoft.com/en-us/dotnet/api/system.io.ioexception?view=net-8.0) (no silent swallowing) and cause-specific logging via `ILoggerService`; `--help` text "Tip:" section promoting `--print-config`; hidden spinner Easter-egg flags remain parseable but are absent from `--help` output and current user-facing documentation; stderr `--print-config` hint on configuration error (exit code `3`), `--wizard` interactive mode redirected-stdin rejection (exit code `2` with error message), `--clear-cache` interactive wizard redirected-stdin rejection (exit code `2` with error message), `--clear-cache` option in `--help` output, `--open-reports`/`--open-config`/`--open-logs` early-exit with folder path output, `--output`/`--config` override integration, default-target order (`Reports` → application data root/config directory → `Logs`) when multiple open flags are combined, short-circuit on first/second-launch failure, and launcher-failure error reporting with the resolved target path, `CheckDiskSpaceOrThrow` empty-path skip, `CheckReportsParentWritableOrThrow` empty-parent skip and probe-file cleanup verification, `--help` output contains open-folder options, IL cache helper methods (`FilterCacheFilesByTool` tool-name matching with short-filename skip, `FilterCacheFilesByToolLabel` exact version matching via sanitize round-trip, `ExtractDistinctToolLabels` distinct sorted label extraction from cache filenames with deduplication and short-filename skip, `UnsanitizeToolLabel` version-pattern reversal and no-version fallback, `SanitizeForCacheMatch` colon/parenthesis replacement, sanitize→unsanitize round-trip identity), custom output directory via `--output` (path override, missing value error, default null), `GetReportsFolderAbsolutePath` with custom/null/empty output directory; `NormalizeDragDropPath` drag-and-drop path normalization (double/single quotes, `file://`/`file:///` URI prefixes, backslash-escaped spaces, percent-encoded characters including Japanese, combined formats, empty input, malformed percent-encoding), unresolved `LocalApplicationData` classification for normal runs and `--open-reports`, help-text wording for the active reports root, and resolved-path stderr diagnostics for invalid user-local vs. bundled fallback config JSON | | Core diff flow | [`FolderDiffExecutionStrategyTests`](../FolderDiffIL4DotNet.Tests/Services/FolderDiffExecutionStrategyTests.cs), [`FolderDiffServiceTests`](../FolderDiffIL4DotNet.Tests/Services/FolderDiffServiceTests.cs), [`FolderDiffServiceUnitTests`](../FolderDiffIL4DotNet.Tests/Services/FolderDiffServiceUnitTests.cs), [`FileDiffServiceTests`](../FolderDiffIL4DotNet.Tests/Services/FileDiffServiceTests.cs), [`FileDiffServiceUnitTests`](../FolderDiffIL4DotNet.Tests/Services/FileDiffServiceUnitTests.cs), [`FileDiffResultListsTests`](../FolderDiffIL4DotNet.Tests/Models/FileDiffResultListsTests.cs), [`DiffExecutionContextTests`](../FolderDiffIL4DotNet.Tests/Services/DiffExecutionContextTests.cs) | Discovery filtering, auto-parallelism policy (I/O-bound `ProcessorCount × 2` for local paths, network-capped for network shares), classification (`Unchanged/Added/Removed/Modified`), diff detail labels, timestamp-regression detection only for **modified** files (unchanged files with reversed timestamps produce no warning), reset behavior, case-insensitive extension handling, propagated text-diff fallback behavior, permission/I/O failure handling, expected-vs-unexpected exception logging/rethrow behavior including stage-aware `FileDiffService` diagnostics (`RelativePath`, `Stage`, `SkipIL`, `MaxParallel`), large-batch classification without real disk I/O, IL-precompute batching for large trees, memory-budget-based throttling of large-text chunk comparison, multi-megabyte real-file text comparison, symlink-backed file classification, per-file hash/IL/text error handling without real disk, symlink-loop [`IOException`](https://learn.microsoft.com/en-us/dotnet/api/system.io.ioexception?view=net-8.0) during enumeration (logged and rethrown), [`FileNotFoundException`](https://learn.microsoft.com/en-us/dotnet/api/system.io.filenotfoundexception?view=net-8.0) during per-file comparison classified as `Removed` with a warning (both sequential and parallel modes), `DiffSummaryStatistics`/`SummaryStatistics` snapshot correctness, completion message routed through `ILoggerService` instead of direct `Console.WriteLine`, `CancellationToken` propagation through diff pipeline (pre-cancelled token throws `OperationCanceledException`), best-effort semantic analysis CA1031 fallback (warning logged but diff result unaffected when `AssemblyMethodAnalyzer.Analyze` fails on non-existent paths), `FolderDiffExecutionStrategy.CountDotNetAssemblyCandidates` empty-input behavior, duplicate-path suppression, managed-assembly-only counting, per-file progress reporting to 100%, `DiffExecutionContext` constructor null-parameter validation (`ArgumentNullException` for each of `oldFolderAbsolutePath`/`newFolderAbsolutePath`/`reportsFolderAbsolutePath`), IL output sub-path derivation from reports folder, and network-share flag preservation | | Dependency analysis | [`DepsJsonAnalyzerTests`](../FolderDiffIL4DotNet.Tests/Services/DepsJsonAnalyzerTests.cs), [`NuGetVersionRangeTests`](../FolderDiffIL4DotNet.Tests/Services/NuGetVersionRangeTests.cs), [`NuGetVulnerabilityServiceTests`](../FolderDiffIL4DotNet.Tests/Services/NuGetVulnerabilityServiceTests.cs) | Structured dependency change analysis for `.deps.json` files: added/removed/updated package detection, importance classification by semver (major=High, minor=Medium, patch=Low), case-insensitive package names, invalid JSON / missing file graceful fallback (returns null), `DependencyChangeSummary` max importance and sort-by-importance ordering, realistic `.deps.json` structure parsing with runtime/compile targets; NuGet version range parsing and containment checks (exact `[x.y.z]`, open/closed intervals `[min, max)`, pre-release suffix stripping, four-part versions, edge cases); NuGet V3 vulnerability API integration (index page URL parsing, vulnerability page parsing, `DepsJsonAnalyzer.Analyze` missing-file `onError` callback invocation, successful-analysis `onError` non-invocation, invalid targets section graceful handling, per-package vulnerability checking with old/new version detection, resolved vs. newly-introduced vulnerabilities, severity label mapping, `DependencyChangeSummary` vulnerability count properties, retry after transient load failure, `OperationCanceledException` propagation, per-page warning logs that retain exception-type context, vulnerability index fetch failure logging with index URL context, outer best-effort failure logging with entry/package counts, index fetch `OperationCanceledException` propagation) | -| IL/disassembler behavior | [`ILOutputServiceTests`](../FolderDiffIL4DotNet.Tests/Services/ILOutputServiceTests.cs), [`ILBlockParserTests`](../FolderDiffIL4DotNet.Tests/Services/ILBlockParserTests.cs), [`DotNetDisassembleServiceTests`](../FolderDiffIL4DotNet.Tests/Services/DotNetDisassembleServiceTests.cs), [`DisassemblerBlacklistTests`](../FolderDiffIL4DotNet.Tests/Services/DisassemblerBlacklistTests.cs), [`DisassemblerHelperTests`](../FolderDiffIL4DotNet.Tests/Services/DisassemblerHelperTests.cs), [`DotNetDisassemblerCacheTests`](../FolderDiffIL4DotNet.Tests/Services/Caching/DotNetDisassemblerCacheTests.cs), [`DotNetDetectorTests`](../FolderDiffIL4DotNet.Tests/Core/Diagnostics/DotNetDetectorTests.cs), [`AssemblyMethodAnalyzerTests`](../FolderDiffIL4DotNet.Tests/Services/AssemblyMethodAnalyzerTests.cs), [`AssemblySemanticChangesSummaryTests`](../FolderDiffIL4DotNet.Tests/Models/AssemblySemanticChangesSummaryTests.cs), [`ChangeImportanceClassifierTests`](../FolderDiffIL4DotNet.Tests/Services/ChangeImportanceClassifierTests.cs), [`ChangeTagClassifierTests`](../FolderDiffIL4DotNet.Tests/Services/ChangeTagClassifierTests.cs), [`CompilerGeneratedResolverTests`](../FolderDiffIL4DotNet.Tests/Services/CompilerGeneratedResolverTests.cs) | Same-disassembler pairing, fallback behavior, block-aware order-independent IL comparison (`BlockAwareSequenceEqual`) with method reordering tolerance, IL block parsing (`ILBlockParser.ParseBlocks`) for preamble/method/class/nested-brace handling, blacklist logic (including TTL-boundary expiry: entry removed and tool retried after the 10-minute blacklist window), independent per-tool blacklist state, null/whitespace safety for `RegisterFailure`/`ResetFailure` (explicit branch coverage for the null guard true-branch), reset of non-existent command, concurrent `RegisterFailure` with 32 threads, concurrent TTL-expiry race with no exceptions, detection and command handling, failure-vs-non-.NET detection semantics; `ResolveExecutablePath` branch coverage for relative paths containing directory separators (found and not found), whitespace-only `PATH` environment variable, `PATH` entries that are empty strings, and commands found via `PATH` search; Windows-only `EnumerateExecutableNames` tests verifying no duplicate `.exe`/`.cmd`/`.bat` extension variants when the command already carries those suffixes; `ProbeAllCandidates` availability probing returns non-empty list with unique tool names including both `dotnet-ildasm` and `ilspycmd`; shared path-shape diagnostics now keep warning formatting alive even when invalid path characters appear in disassembler command strings; `BuildInstallSuggestion` OS-specific install command presence, bilingual text with `--skip-il` tip, Windows/macOS/Linux PATH guidance; assembly semantic analysis detects `Modified` entries for method access/modifier changes and property/field type/access/modifier changes; `ChangeImportanceClassifier` correctly classifies all importance levels (High for public/protected removal and access narrowing, Medium for public addition and internal removal, Low for body-only and private additions), `WithClassifiedImportance` preserves all fields; `AssemblySemanticChangesSummary` importance counts, max importance, and entries sorted by importance; CA1031 catch-all fallback paths for corrupt/truncated/empty PE files (returns null instead of throwing), asymmetric valid-vs-corrupt assembly pair; streaming IL comparison (`StreamingFilteredSequenceEqual`) with MVID exclusion, configured ignore strings, empty inputs, length mismatch, all-excluded equality, and behavioral equivalence with legacy `SplitAndFilterIlLines` + `SequenceEqual`; `FilterIlLines` line filtering with and without exclusions; `SplitToLines` line splitting for LF, CRLF, empty, null, and trailing-newline inputs; `ChangeTagClassifier` heuristic pattern detection (Extract same-type modified+private added, Inline reverse, Move cross-type, Rename same-type different name, Signature/Access/BodyEdit arrow detection, DepUpdate dependency-only changes), `FormatTags` comma-separated formatting, `GetLabel` label mapping, edge cases (public method not consumed by Extract, same-type not triggering Move); `ValidateILFilterStrings` safety validation (null input, empty input, all-safe strings, short string warning, mixed lengths, exact minimum length boundary); compiler-generated name resolution (`CompilerGeneratedResolver`): async state machine, display class, lambda method, backing field, local function, record clone/synthesized member annotation and detection; `SimpleSignatureTypeProvider.GetGenericInstantiation` arity suffix stripping for deeply nested generics, `GetTypeFromReference` nested type resolution via `ResolutionScope`, runtime assembly generic signature verification (no backtick-arity in generic instantiations); `Analyze` optional `onError` callback invocation on failure and non-invocation on success | -| Real disassembler E2E | [`RealDisassemblerE2ETests`](../FolderDiffIL4DotNet.Tests/Services/RealDisassemblerE2ETests.cs) | Builds the same small class library twice with `Deterministic=false`, confirms the rebuilt DLLs differ by SHA256, and verifies that [`dotnet-ildasm`](https://www.nuget.org/packages/dotnet-ildasm/) still classifies them as `ILMatch` after MVID filtering | +| IL/disassembler behavior | [`ILOutputServiceTests`](../FolderDiffIL4DotNet.Tests/Services/ILOutputServiceTests.cs), [`ILBlockParserTests`](../FolderDiffIL4DotNet.Tests/Services/ILBlockParserTests.cs), [`DotNetDisassembleServiceTests`](../FolderDiffIL4DotNet.Tests/Services/DotNetDisassembleServiceTests.cs), [`DisassemblerBlacklistTests`](../FolderDiffIL4DotNet.Tests/Services/DisassemblerBlacklistTests.cs), [`DisassemblerHelperTests`](../FolderDiffIL4DotNet.Tests/Services/DisassemblerHelperTests.cs), [`DotNetDisassemblerCacheTests`](../FolderDiffIL4DotNet.Tests/Services/Caching/DotNetDisassemblerCacheTests.cs), [`DotNetDetectorTests`](../FolderDiffIL4DotNet.Tests/Core/Diagnostics/DotNetDetectorTests.cs), [`AssemblyMethodAnalyzerTests`](../FolderDiffIL4DotNet.Tests/Services/AssemblyMethodAnalyzerTests.cs), [`AssemblySemanticChangesSummaryTests`](../FolderDiffIL4DotNet.Tests/Models/AssemblySemanticChangesSummaryTests.cs), [`ChangeImportanceClassifierTests`](../FolderDiffIL4DotNet.Tests/Services/ChangeImportanceClassifierTests.cs), [`ChangeTagClassifierTests`](../FolderDiffIL4DotNet.Tests/Services/ChangeTagClassifierTests.cs), [`CompilerGeneratedResolverTests`](../FolderDiffIL4DotNet.Tests/Services/CompilerGeneratedResolverTests.cs) | Same-disassembler pairing, fallback behavior, block-aware order-independent IL comparison (`BlockAwareSequenceEqual`) with method reordering tolerance, IL block parsing (`ILBlockParser.ParseBlocks`) for preamble/method/class/nested-brace handling, blacklist logic (including TTL-boundary expiry: entry removed and tool retried after the 10-minute blacklist window), independent per-tool blacklist state, null/whitespace safety for `RegisterFailure`/`ResetFailure` (explicit branch coverage for the null guard true-branch), reset of non-existent command, concurrent `RegisterFailure` with 32 threads, concurrent TTL-expiry race with no exceptions, detection and command handling, failure-vs-non-.NET detection semantics; `ResolveExecutablePath` branch coverage for relative paths containing directory separators (found and not found), whitespace-only `PATH` environment variable, `PATH` entries that are empty strings, and commands found via `PATH` search; Windows-only `EnumerateExecutableNames` tests verifying no duplicate `.exe`/`.cmd`/`.bat` extension variants when the command already carries those suffixes; `ProbeAllCandidates` availability probing returns non-empty list with unique tool names including both `dotnet-ildasm` and `ilspycmd`; shared path-shape diagnostics now keep warning formatting alive even when invalid path characters appear in disassembler command strings; `BuildInstallSuggestion` OS-specific install command presence, bilingual text with `--skip-il` tip, Windows/macOS/Linux PATH guidance; assembly semantic analysis detects `Modified` entries for method access/modifier changes and property/field type/access/modifier changes; `ChangeImportanceClassifier` correctly classifies all importance levels (High for public/protected removal and access narrowing, Medium for public addition and internal removal, Low for body-only and private additions), `WithClassifiedImportance` preserves all fields; `AssemblySemanticChangesSummary` importance counts, max importance, and entries sorted by importance; CA1031 catch-all fallback paths for corrupt/truncated/empty PE files (returns null instead of throwing), asymmetric valid-vs-corrupt assembly pair; streaming IL comparison (`StreamingFilteredSequenceEqual`) with built-in MVID normalization, configured normalization strings, configured ignore strings, empty inputs, length mismatch, all-excluded equality, and behavioral equivalence with legacy `SplitAndFilterIlLines` + `SequenceEqual`; `FilterIlLines` line filtering with and without exclusions; `SplitToLines` line splitting for LF, CRLF, empty, null, and trailing-newline inputs; `ChangeTagClassifier` heuristic pattern detection (Extract same-type modified+private added, Inline reverse, Move cross-type, Rename same-type different name, Signature/Access/BodyEdit arrow detection, DepUpdate dependency-only changes), `FormatTags` comma-separated formatting, `GetLabel` label mapping, edge cases (public method not consumed by Extract, same-type not triggering Move); `ValidateILIgnoreContainingStrings` safety validation (null input, empty input, all-safe strings, short string warning, mixed lengths, exact minimum length boundary); compiler-generated name resolution (`CompilerGeneratedResolver`): async state machine, display class, lambda method, backing field, local function, record clone/synthesized member annotation and detection; `SimpleSignatureTypeProvider.GetGenericInstantiation` arity suffix stripping for deeply nested generics, `GetTypeFromReference` nested type resolution via `ResolutionScope`, runtime assembly generic signature verification (no backtick-arity in generic instantiations); `Analyze` optional `onError` callback invocation on failure and non-invocation on success | +| Real disassembler E2E | [`RealDisassemblerE2ETests`](../FolderDiffIL4DotNet.Tests/Services/RealDisassemblerE2ETests.cs) | Builds the same small class library twice with `Deterministic=false`, confirms the rebuilt DLLs differ by SHA256, and verifies that [`dotnet-ildasm`](https://www.nuget.org/packages/dotnet-ildasm/) still classifies them as `ILMatch` after MVID normalization | +| Real IL output corpus | [`ILOutputCorpusTests`](../FolderDiffIL4DotNet.Tests/Services/ILOutputCorpusTests.cs), [`Fixtures/ILCorpus`](../FolderDiffIL4DotNet.Tests/Fixtures/ILCorpus/README.md) | Pins real output from `dotnet-ildasm 0.12.2.0` and `ilspycmd 9.1.0.7988`, the fallback baseline for inspecting assemblies targeting .NET 8/9/10, for one signed sample assembly covering ordinary and compiler-generated types, nested methods, generics, async/iterator/lambda shapes, properties/events/fields, AssemblyRefs, and reproducible COM interop metadata; validates corpus completeness, each tool's actual syntax, and that every built-in rule is applied regardless of disassembler provenance | | Folder diff → report E2E | [`FolderDiffReportE2ETests`](../FolderDiffIL4DotNet.Tests/Services/FolderDiffReportE2ETests.cs) | Full pipeline integration: folder diff → Markdown/HTML/audit log generation → output verification; all-unchanged scenario (zero-change summary); subdirectory relative path preservation in all report types; parallel vs sequential classification consistency; audit log SHA256 hashes referencing generated reports (64-char hex, distinct per file); HTML interactive elements (checkboxes, autoSave, collectState, applyFilters, CSP, SHA256 integrity code); inline diff content for modified text files | | Caching | [`ILCacheTests`](../FolderDiffIL4DotNet.Tests/Services/Caching/ILCacheTests.cs), [`ILDiskCacheTests`](../FolderDiffIL4DotNet.Tests/Services/Caching/ILDiskCacheTests.cs), [`ILCachePrefetcherTests`](../FolderDiffIL4DotNet.Tests/Services/ILCachePrefetcherTests.cs), [`ILCacheConcurrencyTests`](../FolderDiffIL4DotNet.Tests/Services/EdgeCases/ILCacheConcurrencyTests.cs), [`ILCacheDiskFailureTests`](../FolderDiffIL4DotNet.Tests/Services/EdgeCases/ILCacheDiskFailureTests.cs) | memory/disk cache semantics, same-key updates at capacity, eviction coordination, keying behavior, concurrent Set/Get under memory and disk modes, LRU eviction under contention, TTL expiry races, disk I/O failure simulation (read-only directory, corrupted files, mid-operation directory deletion, zero-byte cache file, concurrent writes to invalid path), direct disk-layer guard/log coverage for invalid cache-directory initialization, blank cache keys, overlong cache-key path failures, and quota-trim context logging, disk quota enforcement (MaxDiskFileCount trimming, size limit trimming), rapid writes to read-only directory with memory fallback, IL-cache prefetch argument validation (null/empty input, invalid maxParallel), cache-disabled and null-cache prefetch handling, cache-hit counter tracking, constructor null-guard checks | | Edge cases | [`DisassemblerBlacklistTtlRecoveryTests`](../FolderDiffIL4DotNet.Tests/Services/EdgeCases/DisassemblerBlacklistTtlRecoveryTests.cs), [`LargeFileComparisonTests`](../FolderDiffIL4DotNet.Tests/Services/EdgeCases/LargeFileComparisonTests.cs), [`SymlinkAndCircularDirectoryTests`](../FolderDiffIL4DotNet.Tests/Services/EdgeCases/SymlinkAndCircularDirectoryTests.cs), [`FolderDiffConcurrencyStressTests`](../FolderDiffIL4DotNet.Tests/Services/EdgeCases/FolderDiffConcurrencyStressTests.cs) | TTL recovery cycles and concurrent register/check for DisassemblerBlacklist, 4 MiB file chunk-parallel comparison, symlink loop/dangling handling, 500-file parallel classification determinism, simulated latency stress test | @@ -49,7 +50,7 @@ Version metadata regression coverage verifies that `SystemInfo` separates the th | Git test isolation | [`TestGitRepositoryTests`](../FolderDiffIL4DotNet.Tests/Helpers/TestGitRepositoryTests.cs) | temporary Git repositories use one shared initialization path, ignore user/system Git configuration, disable commit/tag signing and hooks locally, run non-interactively, preserve the simulated global configuration file, and still create commits and annotated tags when the isolated global configuration enables signing | | Plugin system | [`PluginLoaderTests`](../FolderDiffIL4DotNet.Tests/Runner/PluginLoaderTests.cs), [`FileDiffServiceUnitTests.Hooks`](../FolderDiffIL4DotNet.Tests/Services/FileDiffServiceUnitTests.Hooks.cs), [`DotNetDisassemblerProviderTests`](../FolderDiffIL4DotNet.Tests/Services/DotNetDisassemblerProviderTests.cs), [`PluginConfigSettingsTests`](../FolderDiffIL4DotNet.Tests/Models/PluginConfigSettingsTests.cs), [`ReportFormatterTests`](../FolderDiffIL4DotNet.Tests/Services/ReportFormatterTests.cs), [`ReportSectionWriterOrderTests`](../FolderDiffIL4DotNet.Tests/Services/ReportSectionWriterOrderTests.cs) | Plugin loading from search paths (non-existent path, empty directory, invalid DLL, duplicate search-path deduplication for the same plugin DLL, constructor null-guard, strict-mode pre-load hash rejection when trusted hashes are missing/empty/mismatched), `IFileComparisonHook` before/after integration (hook override, null passthrough, exception handling, phase-aware failure logs with hook order, multi-hook ordering), `DotNetDisassemblerProvider` CanHandle for .NET vs non-.NET files, DisplayName/extension-aware warning logs on recoverable detection failures, DisassembleAsync delegation and error handling, plugin config settings round-trip (PluginSearchPaths, PluginEnabledIds, PluginConfig JSON serialization, readonly guarantees), `IReportFormatter` Order values and IsEnabled conditions, `IReportSectionWriter` built-in writer count and unique/increasing order invariants | | Runner layer | [`CliOverrideApplierTests`](../FolderDiffIL4DotNet.Tests/Runner/CliOverrideApplierTests.cs), [`DiffPipelineExecutorTests`](../FolderDiffIL4DotNet.Tests/Runner/DiffPipelineExecutorTests.cs), [`DryRunExecutorTests`](../FolderDiffIL4DotNet.Tests/Runner/DryRunExecutorTests.cs), [`RunScopeBuilderTests`](../FolderDiffIL4DotNet.Tests/Runner/RunScopeBuilderTests.cs), [`PluginAssemblyLoadContextTests`](../FolderDiffIL4DotNet.Tests/Runner/PluginAssemblyLoadContextTests.cs), [`SpinnerThemesTests`](../FolderDiffIL4DotNet.Tests/Runner/SpinnerThemesTests.cs) | CLI override application (`--threads`, `--no-il-cache`, `--skip-il`, `--no-timestamp-warnings` applied to builder, default options leave builder unchanged), `FormatElapsedTime` formatting (zero, sub-second, multi-hour), `DiffPipelineResult` record equality (including `HasILFilterWarnings` field), constructor null-guards, `RunScopeBuilder.BuildExecutionContext` network detection and path storage, `CreateIlCache` conditional creation, DI container service resolution, `PluginAssemblyLoadContext` collectible context creation and shared assembly fallback, post-process action failures staying best-effort while warning logs retain the action type, execution position, `Order`, and exception type, and hidden spinner Easter-egg compatibility behavior | -| Section writer individual | [`HeaderSectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/HeaderSectionWriterTests.cs), [`LegendSectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/LegendSectionWriterTests.cs), [`ConditionalSectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/ConditionalSectionWriterTests.cs), [`FileListSectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/FileListSectionWriterTests.cs), [`SummarySectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/SummarySectionWriterTests.cs), [`IgnoredFilesSectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/IgnoredFilesSectionWriterTests.cs), [`ILCacheStatsSectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/ILCacheStatsSectionWriterTests.cs), [`WarningsSectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/WarningsSectionWriterTests.cs) | Per-writer Order values, IsEnabled conditions (IgnoredFiles config flag, UnchangedFiles config flag, ILCacheStats config+cache null, Warnings SHA256 mismatch/timestamp regression/IL filter string validation), Write output content assertions (report title, app version, paths, computer name, legend SHA256, section headers for Added/Removed/Modified/Unchanged, summary elapsed time, warning keyword, IL filter warning text), empty/zero-count result list handling, Ignored early return on empty list, ILCacheStats disabled when cache is null | +| Section writer individual | [`HeaderSectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/HeaderSectionWriterTests.cs), [`LegendSectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/LegendSectionWriterTests.cs), [`ConditionalSectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/ConditionalSectionWriterTests.cs), [`FileListSectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/FileListSectionWriterTests.cs), [`SummarySectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/SummarySectionWriterTests.cs), [`IgnoredFilesSectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/IgnoredFilesSectionWriterTests.cs), [`ILCacheStatsSectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/ILCacheStatsSectionWriterTests.cs), [`WarningsSectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/WarningsSectionWriterTests.cs) | Per-writer Order values, IsEnabled conditions (IgnoredFiles config flag, UnchangedFiles config flag, ILCacheStats config+cache null, Warnings SHA256 mismatch/timestamp regression/IL substring configuration safety), Write output content assertions (report title, app version, paths, computer name, legend SHA256, section headers for Added/Removed/Modified/Unchanged, summary elapsed time, warning keyword, IL substring configuration safety warning text), empty/zero-count result list handling, Ignored early return on empty list, ILCacheStats disabled when cache is null | | Plugin unload/cleanup | [`PluginUnloadCleanupTests`](../FolderDiffIL4DotNet.Tests/Runner/PluginUnloadCleanupTests.cs) | Collectible `AssemblyLoadContext` unload (clean unload, unload after assembly load, multiple independent contexts), `WeakReference` GC reclamation after unload, double-unload `InvalidOperationException`, `Unloading` event firing, shared `Plugin.Abstractions` assembly survives plugin unload | | Security (XSS/path traversal) | [`HtmlReportSecurityTests`](../FolderDiffIL4DotNet.Tests/Security/HtmlReportSecurityTests.cs), [`PathTraversalSecurityTests`](../FolderDiffIL4DotNet.Tests/Security/PathTraversalSecurityTests.cs) | `HtmlEncode` XSS prevention (script tags, attribute injection, template literal backticks, double-encoding, control characters, CJK text pass-through, malicious file paths, all five HTML special chars, null/empty), `PathValidator` traversal prevention (relative escapes, dot/dotdot, null byte/control chars, Windows reserved names, trailing space/dot, absolute paths, Unicode acceptance, extreme path length) | | Network share optimization | [`NetworkPathDetectorTests`](../FolderDiffIL4DotNet.Tests/Core/IO/NetworkPathDetectorTests.cs) | `NetworkPathDetector.IsLikelyNetworkPath` UNC detection (backslash, forward-slash, device path formats), null/empty/whitespace rejection, local path rejection. The same file also covers `DiffExecutionContext` network flag propagation (`OptimizeForNetworkShares`, `DetectedNetworkOld/New`), null path `ArgumentNullException`, and IL output path derivation | @@ -288,8 +289,9 @@ Workflow/config files: [`.github/workflows/dotnet.yml`](../.github/workflows/dot | エントリーポイント/設定 | [`ProgramTests`](../FolderDiffIL4DotNet.Tests/ProgramTests.cs), [`ProgramRunnerTests`](../FolderDiffIL4DotNet.Tests/ProgramRunnerTests.cs), [`CliOptionsTests`](../FolderDiffIL4DotNet.Tests/CliOptionsTests.cs), [`ConfigServiceTests`](../FolderDiffIL4DotNet.Tests/Services/ConfigServiceTests.cs), [`ConfigSettingsTests`](../FolderDiffIL4DotNet.Tests/Models/ConfigSettingsTests.cs) | `Main` の終了コード、引数不正と設定失敗を分ける [`ProgramRunner`](../ProgramRunner.cs) の型付き終了コード分類、引数検証と設定読込の順序、最小構成の実行、[`CliParser`](../Runner/CliParser.cs) による CLI 引数解析(null/空/位置引数のみ、文書化済みの全フラグと非表示のスピナーイースターエッグ互換フラグ、未知フラグ検出、全フラグ組み合わせ、`--config`/`--threads` の値欠落エラー、スピナー最後勝ち動作、複数スピナー検出(`MultipleSpinnersDetected`))、コード既定値(`ConfigSettings.Default*` 名前付き定数で検証)と override の設定挙動、更新日時警告のコンソール/レポート出力、[`ProgramRunner`](../ProgramRunner.cs) が内部 IL キャッシュ既定値をどう配線するかの検証、完了サマリーチャート出力(各ファイルカテゴリのバー描画、合計ゼロ時のスキップ、ラベル揃え)、JSON 書式エラーの報告(オブジェクト末尾カンマ `{"Key":"v",}`、配列末尾カンマ `["a","b",]`、複数行 JSON での [`InvalidDataException`](https://learn.microsoft.com/ja-jp/dotnet/api/system.io.invaliddataexception?view=net-8.0) メッセージへの行番号付与、`line N, position N` パターン検証、全 JSON パースエラーへのトレイリングカンマヒント付与、カスタム設定パスの `FileNotFoundException` でパスがメッセージに含まれること)、プリフライト書込権限チェックにおける [`IOException`](https://learn.microsoft.com/ja-jp/dotnet/api/system.io.ioexception?view=net-8.0) の fail-fast 処理(サイレントスワロー排除)と `ILoggerService` 経由の原因別ログ出力、`--help` テキストの「Tip:」セクションで `--print-config` を紹介、非表示のスピナーイースターエッグフラグを解析できる一方で `--help` 出力と現行ユーザー向け文書には掲載しないこと、設定エラー(終了コード `3`)時の stderr への `--print-config` ヒント出力、`--wizard` 対話モードのリダイレクト stdin 拒否(終了コード `2` とエラーメッセージ)、`--clear-cache` 対話ウィザードのリダイレクト stdin 拒否(終了コード `2` とエラーメッセージ)、`--help` 出力に `--clear-cache` オプション表示、`--open-reports`/`--open-config`/`--open-logs` 早期終了、フォルダパス出力、`--output`/`--config` 指定時の連携、複数フラグ同時指定時の既定ターゲット順(`Reports` → アプリケーションデータルート / 設定ディレクトリ → `Logs`)、1 件目/2 件目の起動失敗時の short-circuit、および起動処理失敗時に解決済みターゲットパス付きでエラーを返すこと、`CheckDiskSpaceOrThrow` 空パススキップ、`CheckReportsParentWritableOrThrow` 空親パススキップとプローブファイルクリーンアップ検証、`--help` 出力にフォルダ開放オプション表示、IL キャッシュヘルパーメソッド(`FilterCacheFilesByTool` ツール名マッチングと短ファイル名スキップ、`FilterCacheFilesByToolLabel` サニタイズ往復による正確なバージョンマッチング、`ExtractDistinctToolLabels` キャッシュファイル名からの一意ソート済みラベル抽出と重複排除・短ファイル名スキップ、`UnsanitizeToolLabel` バージョンパターン逆変換とバージョンなしフォールバック、`SanitizeForCacheMatch` コロン/括弧置換、サニタイズ→逆サニタイズ往復一致性)、`--output` によるカスタム出力ディレクトリ(パス指定・値欠落エラー・デフォルト null)、`GetReportsFolderAbsolutePath` のカスタム/null/空出力ディレクトリ、`NormalizeDragDropPath` ドラッグ&ドロップパス正規化(二重/単一引用符、`file://`/`file:///` URI プレフィックス、バックスラッシュエスケープ空白、日本語を含むパーセントエンコード文字、複合形式、空入力、不正パーセントエンコーディング)、未解決 `LocalApplicationData` を通常実行と `--open-reports` でどう分類するか、active reports root に合わせた `--help` 文言、および user-local / bundled fallback の不正 JSON に対する解決済みパス付き stderr 診断 | | 差分処理本体 | [`FolderDiffExecutionStrategyTests`](../FolderDiffIL4DotNet.Tests/Services/FolderDiffExecutionStrategyTests.cs), [`FolderDiffServiceTests`](../FolderDiffIL4DotNet.Tests/Services/FolderDiffServiceTests.cs), [`FolderDiffServiceUnitTests`](../FolderDiffIL4DotNet.Tests/Services/FolderDiffServiceUnitTests.cs), [`FileDiffServiceTests`](../FolderDiffIL4DotNet.Tests/Services/FileDiffServiceTests.cs), [`FileDiffServiceUnitTests`](../FolderDiffIL4DotNet.Tests/Services/FileDiffServiceUnitTests.cs), [`FileDiffResultListsTests`](../FolderDiffIL4DotNet.Tests/Models/FileDiffResultListsTests.cs), [`DiffExecutionContextTests`](../FolderDiffIL4DotNet.Tests/Services/DiffExecutionContextTests.cs) | 列挙フィルタ、自動並列度ポリシー(I/O バウンドのローカルパスでは `ProcessorCount × 2`、ネットワーク共有では上限キャップ)、`Unchanged/Added/Removed/Modified` の分類、判定理由、**Modified と判定されたファイルのみ**を対象とした更新日時逆転検出(Unchanged ファイルは更新日時が逆転しても警告対象外)、状態リセット、拡張子大小無視、伝播したテキスト比較例外からのフォールバック、権限エラー/出力先 I/O 失敗、想定例外と想定外例外のログ/再スロー境界、大量ファイルの扱い、大規模ツリー向け IL 事前計算バッチ化、大きいテキスト比較のメモリ予算ベース抑制、複数 MiB の実ファイル比較、シンボリックリンク経由の分類、ファイル単位のハッシュ/IL/テキスト分岐の異常系、列挙時のシンボリックリンクループ [`IOException`](https://learn.microsoft.com/ja-jp/dotnet/api/system.io.ioexception?view=net-8.0)(ログ出力のうえ再スロー)、比較前ファイル削除時の [`FileNotFoundException`](https://learn.microsoft.com/ja-jp/dotnet/api/system.io.filenotfoundexception?view=net-8.0) を `Removed` 分類+警告(逐次・並列両対応)、`DiffSummaryStatistics`/`SummaryStatistics` のスナップショット正確性、完了メッセージが直接 `Console.WriteLine` ではなく `ILoggerService` 経由で出力されることの検証、差分パイプラインへの `CancellationToken` 伝播(キャンセル済みトークンで `OperationCanceledException` がスローされること)、ベストエフォートセマンティック解析の CA1031 フォールバック(`AssemblyMethodAnalyzer.Analyze` が存在しないパスで失敗した場合に警告ログのみで差分結果に影響しないこと)、`FolderDiffExecutionStrategy.CountDotNetAssemblyCandidates` の空入力時挙動、重複パス抑止、マネージドアセンブリのみのカウント、ファイル単位の進捗 100% 到達、`DiffExecutionContext` コンストラクタの null パラメータ検証(`oldFolderAbsolutePath`/`newFolderAbsolutePath`/`reportsFolderAbsolutePath` 各 `ArgumentNullException`)、レポートフォルダからの IL 出力サブパス導出、ネットワーク共有フラグの保持 | | 依存関係分析 | [`DepsJsonAnalyzerTests`](../FolderDiffIL4DotNet.Tests/Services/DepsJsonAnalyzerTests.cs)、[`NuGetVersionRangeTests`](../FolderDiffIL4DotNet.Tests/Services/NuGetVersionRangeTests.cs)、[`NuGetVulnerabilityServiceTests`](../FolderDiffIL4DotNet.Tests/Services/NuGetVulnerabilityServiceTests.cs) | `.deps.json` ファイルの構造化された依存関係変更分析: パッケージの追加/削除/更新検出、semver による重要度分類(メジャー=High、マイナー=Medium、パッチ=Low)、大小区別なしのパッケージ名、不正 JSON/ファイル不在での安全なフォールバック(null 返却)、`DependencyChangeSummary` の最大重要度と重要度順ソート、runtime/compile ターゲット付きの実際の `.deps.json` 構造パース;NuGet バージョン範囲の解析と包含判定(完全一致 `[x.y.z]`、開区間/閉区間 `[min, max)`、プレリリースサフィックス除去、4 パートバージョン、エッジケース);NuGet V3 脆弱性 API 連携(インデックスページ URL パース、脆弱性ページパース、`DepsJsonAnalyzer.Analyze` ファイル不在時の `onError` コールバック呼び出し、正常解析時の `onError` 非呼び出し、不正 targets セクションのグレースフルハンドリング、旧/新バージョン検出によるパッケージ別脆弱性チェック、解消済み vs 新規導入の脆弱性判定、深刻度ラベルマッピング、`DependencyChangeSummary` 脆弱性カウントプロパティ、一時障害後の再試行、`OperationCanceledException` の伝播、例外型を保持したページ単位の警告ログ、脆弱性インデックス取得失敗時の index URL 付き警告、外側 best-effort failure ログの entry/package 件数、インデックス取得時の `OperationCanceledException` 伝播) | -| IL/逆アセンブラ | [`ILOutputServiceTests`](../FolderDiffIL4DotNet.Tests/Services/ILOutputServiceTests.cs), [`ILBlockParserTests`](../FolderDiffIL4DotNet.Tests/Services/ILBlockParserTests.cs), [`DotNetDisassembleServiceTests`](../FolderDiffIL4DotNet.Tests/Services/DotNetDisassembleServiceTests.cs), [`DisassemblerBlacklistTests`](../FolderDiffIL4DotNet.Tests/Services/DisassemblerBlacklistTests.cs), [`DisassemblerHelperTests`](../FolderDiffIL4DotNet.Tests/Services/DisassemblerHelperTests.cs), [`DotNetDisassemblerCacheTests`](../FolderDiffIL4DotNet.Tests/Services/Caching/DotNetDisassemblerCacheTests.cs), [`DotNetDetectorTests`](../FolderDiffIL4DotNet.Tests/Core/Diagnostics/DotNetDetectorTests.cs), [`AssemblyMethodAnalyzerTests`](../FolderDiffIL4DotNet.Tests/Services/AssemblyMethodAnalyzerTests.cs), [`AssemblySemanticChangesSummaryTests`](../FolderDiffIL4DotNet.Tests/Models/AssemblySemanticChangesSummaryTests.cs), [`ChangeImportanceClassifierTests`](../FolderDiffIL4DotNet.Tests/Services/ChangeImportanceClassifierTests.cs), [`ChangeTagClassifierTests`](../FolderDiffIL4DotNet.Tests/Services/ChangeTagClassifierTests.cs), [`CompilerGeneratedResolverTests`](../FolderDiffIL4DotNet.Tests/Services/CompilerGeneratedResolverTests.cs) | 同一逆アセンブラ比較、フォールバック、ブロック単位順序非依存 IL 比較(`BlockAwareSequenceEqual`)によるメソッド並び替え耐性、IL ブロック解析(`ILBlockParser.ParseBlocks`)のプリアンブル/メソッド/クラス/ネスト波括弧対応、ブラックリスト(TTL 境界: 10 分のブラックリスト期間経過後にエントリが削除され、ツールが再試行されることを含む)、ツール独立状態、`RegisterFailure`/`ResetFailure` の null/空白文字ガード(null ガードの true 分岐を明示的にカバー)、存在しないコマンドの reset、32 スレッドの並行 `RegisterFailure`、TTL 切れ境界での並行呼び出し(例外なし)、検出・コマンド処理、判定失敗と非 .NET の区別;`ResolveExecutablePath` のブランチ網羅(ディレクトリ区切り文字を含む相対パス(存在あり/なし)、空白のみの `PATH` 環境変数、空文字列の PATH エントリ、PATH 検索によるコマンド発見);Windows 専用 `EnumerateExecutableNames` テスト(`.exe`/`.cmd`/`.bat` 拡張子を持つコマンドに重複拡張子が追加されないことを検証);`ProbeAllCandidates` 利用可否プローブが `dotnet-ildasm` と `ilspycmd` の両方を含む一意ツール名の非空リストを返すこと;`BuildInstallSuggestion` OS 別インストールコマンドの存在、英日バイリンガルテキストと `--skip-il` ヒント、Windows/macOS/Linux の PATH 案内;アセンブリセマンティック解析がメソッドのアクセス/修飾子変更およびプロパティ/フィールドの型/アクセス/修飾子変更を `Modified` エントリとして検出;`ChangeImportanceClassifier` が全重要度レベルを正しく分類(public/protected 削除やアクセス縮小は High、public 追加や internal 削除は Medium、ボディのみ変更や private 追加は Low)、`WithClassifiedImportance` が全フィールドを保持;`AssemblySemanticChangesSummary` の重要度カウント・最大重要度・重要度順ソート、CA1031 catch-all フォールバック:破損/切り詰め/空の PE ファイルで例外ではなく null を返すこと、正常と破損の非対称アセンブリペア;ストリーミング IL 比較(`StreamingFilteredSequenceEqual`)の MVID 除外・設定文字列除外・空入力・長さ不一致・全行除外時の一致・従来の `SplitAndFilterIlLines` + `SequenceEqual` との動作等価性検証;`FilterIlLines` の除外あり/なし行フィルタリング;`SplitToLines` の LF・CRLF・空文字列・null・末尾改行入力の行分割;`ChangeTagClassifier` のヒューリスティックパターン検出(Extract 同一型内 Modified+private 追加、Inline 逆パターン、Move 型間移動、Rename 同一型で名前変更、Signature/Access/BodyEdit のアロー検出、DepUpdate 依存関係のみの変更)、`FormatTags` カンマ区切りフォーマット、`GetLabel` ラベルマッピング、エッジケース(public メソッドが Extract に消費されないこと、同一型で Move がトリガーされないこと);`ValidateILFilterStrings` 安全性検証(null 入力、空入力、全て安全な文字列、短い文字列の警告、混合長、最小長ちょうどの境界値);コンパイラ生成名解決(`CompilerGeneratedResolver`):async ステートマシン、ディスプレイクラス、ラムダメソッド、バッキングフィールド、ローカル関数、record クローン/合成メンバーの注釈と検出;`SimpleSignatureTypeProvider.GetGenericInstantiation` 深いネストを含むジェネリクスのアリティサフィックス除去、`GetTypeFromReference` `ResolutionScope` 経由のネスト型解決、ランタイムアセンブリのジェネリックシグネチャにバッククォートアリティが含まれないことの検証;`Analyze` オプショナル `onError` コールバックの失敗時呼び出しと成功時非呼び出し | -| 実逆アセンブラ E2E | [`RealDisassemblerE2ETests`](../FolderDiffIL4DotNet.Tests/Services/RealDisassemblerE2ETests.cs) | `Deterministic=false` の同一クラスライブラリを 2 回ビルドし、再ビルド DLL が SHA256 では不一致でも、[`dotnet-ildasm`](https://www.nuget.org/packages/dotnet-ildasm/) では MVID 除外後に `ILMatch` になることを検証 | +| IL/逆アセンブラ | [`ILOutputServiceTests`](../FolderDiffIL4DotNet.Tests/Services/ILOutputServiceTests.cs), [`ILBlockParserTests`](../FolderDiffIL4DotNet.Tests/Services/ILBlockParserTests.cs), [`DotNetDisassembleServiceTests`](../FolderDiffIL4DotNet.Tests/Services/DotNetDisassembleServiceTests.cs), [`DisassemblerBlacklistTests`](../FolderDiffIL4DotNet.Tests/Services/DisassemblerBlacklistTests.cs), [`DisassemblerHelperTests`](../FolderDiffIL4DotNet.Tests/Services/DisassemblerHelperTests.cs), [`DotNetDisassemblerCacheTests`](../FolderDiffIL4DotNet.Tests/Services/Caching/DotNetDisassemblerCacheTests.cs), [`DotNetDetectorTests`](../FolderDiffIL4DotNet.Tests/Core/Diagnostics/DotNetDetectorTests.cs), [`AssemblyMethodAnalyzerTests`](../FolderDiffIL4DotNet.Tests/Services/AssemblyMethodAnalyzerTests.cs), [`AssemblySemanticChangesSummaryTests`](../FolderDiffIL4DotNet.Tests/Models/AssemblySemanticChangesSummaryTests.cs), [`ChangeImportanceClassifierTests`](../FolderDiffIL4DotNet.Tests/Services/ChangeImportanceClassifierTests.cs), [`ChangeTagClassifierTests`](../FolderDiffIL4DotNet.Tests/Services/ChangeTagClassifierTests.cs), [`CompilerGeneratedResolverTests`](../FolderDiffIL4DotNet.Tests/Services/CompilerGeneratedResolverTests.cs) | 同一逆アセンブラ比較、フォールバック、ブロック単位順序非依存 IL 比較(`BlockAwareSequenceEqual`)によるメソッド並び替え耐性、IL ブロック解析(`ILBlockParser.ParseBlocks`)のプリアンブル/メソッド/クラス/ネスト波括弧対応、ブラックリスト(TTL 境界: 10 分のブラックリスト期間経過後にエントリが削除され、ツールが再試行されることを含む)、ツール独立状態、`RegisterFailure`/`ResetFailure` の null/空白文字ガード(null ガードの true 分岐を明示的にカバー)、存在しないコマンドの reset、32 スレッドの並行 `RegisterFailure`、TTL 切れ境界での並行呼び出し(例外なし)、検出・コマンド処理、判定失敗と非 .NET の区別;`ResolveExecutablePath` のブランチ網羅(ディレクトリ区切り文字を含む相対パス(存在あり/なし)、空白のみの `PATH` 環境変数、空文字列の PATH エントリ、PATH 検索によるコマンド発見);Windows 専用 `EnumerateExecutableNames` テスト(`.exe`/`.cmd`/`.bat` 拡張子を持つコマンドに重複拡張子が追加されないことを検証);`ProbeAllCandidates` 利用可否プローブが `dotnet-ildasm` と `ilspycmd` の両方を含む一意ツール名の非空リストを返すこと;`BuildInstallSuggestion` OS 別インストールコマンドの存在、英日バイリンガルテキストと `--skip-il` ヒント、Windows/macOS/Linux の PATH 案内;アセンブリセマンティック解析がメソッドのアクセス/修飾子変更およびプロパティ/フィールドの型/アクセス/修飾子変更を `Modified` エントリとして検出;`ChangeImportanceClassifier` が全重要度レベルを正しく分類(public/protected 削除やアクセス縮小は High、public 追加や internal 削除は Medium、ボディのみ変更や private 追加は Low)、`WithClassifiedImportance` が全フィールドを保持;`AssemblySemanticChangesSummary` の重要度カウント・最大重要度・重要度順ソート、CA1031 catch-all フォールバック:破損/切り詰め/空の PE ファイルで例外ではなく null を返すこと、正常と破損の非対称アセンブリペア;ストリーミング IL 比較(`StreamingFilteredSequenceEqual`)の組み込み MVID 正規化・設定正規化文字列・設定文字列除外・空入力・長さ不一致・全行除外時の一致・従来の `SplitAndFilterIlLines` + `SequenceEqual` との動作等価性検証;`FilterIlLines` の除外あり/なし行フィルタリング;`SplitToLines` の LF・CRLF・空文字列・null・末尾改行入力の行分割;`ChangeTagClassifier` のヒューリスティックパターン検出(Extract 同一型内 Modified+private 追加、Inline 逆パターン、Move 型間移動、Rename 同一型で名前変更、Signature/Access/BodyEdit のアロー検出、DepUpdate 依存関係のみの変更)、`FormatTags` カンマ区切りフォーマット、`GetLabel` ラベルマッピング、エッジケース(public メソッドが Extract に消費されないこと、同一型で Move がトリガーされないこと);`ValidateILIgnoreContainingStrings` 安全性検証(null 入力、空入力、全て安全な文字列、短い文字列の警告、混合長、最小長ちょうどの境界値);コンパイラ生成名解決(`CompilerGeneratedResolver`):async ステートマシン、ディスプレイクラス、ラムダメソッド、バッキングフィールド、ローカル関数、record クローン/合成メンバーの注釈と検出;`SimpleSignatureTypeProvider.GetGenericInstantiation` 深いネストを含むジェネリクスのアリティサフィックス除去、`GetTypeFromReference` `ResolutionScope` 経由のネスト型解決、ランタイムアセンブリのジェネリックシグネチャにバッククォートアリティが含まれないことの検証;`Analyze` オプショナル `onError` コールバックの失敗時呼び出しと成功時非呼び出し | +| 実逆アセンブラ E2E | [`RealDisassemblerE2ETests`](../FolderDiffIL4DotNet.Tests/Services/RealDisassemblerE2ETests.cs) | `Deterministic=false` の同一クラスライブラリを 2 回ビルドし、再ビルド DLL が SHA256 では不一致でも、[`dotnet-ildasm`](https://www.nuget.org/packages/dotnet-ildasm/) では MVID 正規化後に `ILMatch` になることを検証 | +| 実 IL 出力コーパス | [`ILOutputCorpusTests`](../FolderDiffIL4DotNet.Tests/Services/ILOutputCorpusTests.cs)、[`Fixtures/ILCorpus`](../FolderDiffIL4DotNet.Tests/Fixtures/ILCorpus/README.md) | 通常型とcompiler-generated type、class内method、generic、async/iterator/lambda、property/event/field、AssemblyRef、再現可能なCOM interop metadataを含む1つの署名済みサンプルAssemblyについて、`dotnet-ildasm 0.12.2.0` と、.NET 8/9/10をtarget frameworkとするAssemblyを調査するfallback baselineである `ilspycmd 9.1.0.7988` の実出力を固定し、コーパスの完全性、各ツールの実構文、由来に関係なく全組み込み規則が適用されることを検証 | | フォルダ差分→レポート E2E | [`FolderDiffReportE2ETests`](../FolderDiffIL4DotNet.Tests/Services/FolderDiffReportE2ETests.cs) | フルパイプライン統合テスト:フォルダ差分 → Markdown/HTML/監査ログ生成 → 出力検証、全ファイル変更なしシナリオ(ゼロ変更サマリー)、サブディレクトリ相対パスの全レポート種別での保持、並列/逐次分類の整合性、監査ログの SHA256 ハッシュが生成レポートを参照すること(64文字16進、ファイルごとに異なる)、HTML インタラクティブ要素(チェックボックス、autoSave、collectState、applyFilters、CSP、SHA256 整合性コード)、変更テキストファイルへのインライン差分コンテンツ | | キャッシュ | [`ILCacheTests`](../FolderDiffIL4DotNet.Tests/Services/Caching/ILCacheTests.cs)、[`ILDiskCacheTests`](../FolderDiffIL4DotNet.Tests/Services/Caching/ILDiskCacheTests.cs)、[`ILCachePrefetcherTests`](../FolderDiffIL4DotNet.Tests/Services/ILCachePrefetcherTests.cs)、[`ILCacheConcurrencyTests`](../FolderDiffIL4DotNet.Tests/Services/EdgeCases/ILCacheConcurrencyTests.cs)、[`ILCacheDiskFailureTests`](../FolderDiffIL4DotNet.Tests/Services/EdgeCases/ILCacheDiskFailureTests.cs) | メモリ/ディスクキャッシュの保持、同一キー再保存、退避時の連動削除、キー生成、メモリ/ディスクモードでの並列 Set/Get、競合下の LRU 退去、TTL 期限切れレース、ディスク I/O 障害シミュレーション(読み取り専用ディレクトリ、破損ファイル、操作中ディレクトリ削除、0バイトキャッシュファイル、無効パスへの並行書き込み)、無効なキャッシュディレクトリ初期化・空キャッシュキー・長すぎるキャッシュキー由来のパス失敗・クォータトリム文脈ログに対するディスク層の直接テスト、ディスククォータ適用(MaxDiskFileCount トリミング、サイズ制限トリミング)、読み取り専用ディレクトリへの高速連続書き込みとメモリフォールバック、IL キャッシュプリフェッチの引数バリデーション(null/空入力、不正 maxParallel)、キャッシュ無効時および null キャッシュ時のプリフェッチ処理、キャッシュヒットカウンタの追跡、コンストラクタ null ガードチェック | | エッジケース | [`DisassemblerBlacklistTtlRecoveryTests`](../FolderDiffIL4DotNet.Tests/Services/EdgeCases/DisassemblerBlacklistTtlRecoveryTests.cs)、[`LargeFileComparisonTests`](../FolderDiffIL4DotNet.Tests/Services/EdgeCases/LargeFileComparisonTests.cs)、[`SymlinkAndCircularDirectoryTests`](../FolderDiffIL4DotNet.Tests/Services/EdgeCases/SymlinkAndCircularDirectoryTests.cs)、[`FolderDiffConcurrencyStressTests`](../FolderDiffIL4DotNet.Tests/Services/EdgeCases/FolderDiffConcurrencyStressTests.cs) | DisassemblerBlacklist の TTL 復旧サイクルと並列 register/check、4 MiB ファイルのチャンク並列比較、シンボリックリンクループ/ダングリング処理、500 ファイル並列分類の決定論性、レイテンシシミュレーション付きストレステスト | @@ -301,7 +303,7 @@ Workflow/config files: [`.github/workflows/dotnet.yml`](../.github/workflows/dot | Git テスト分離 | [`TestGitRepositoryTests`](../FolderDiffIL4DotNet.Tests/Helpers/TestGitRepositoryTests.cs) | 一時 Git リポジトリが単一の共通初期化経路を使い、ユーザー/システム Git 設定を読み込まず、commit/tag 署名と hook をローカルで無効化して非対話実行すること、擬似グローバル設定ファイルを変更しないこと、分離済みグローバル設定で署名を有効化しても commit と annotated tag を作成できること | | プラグインシステム | [`PluginLoaderTests`](../FolderDiffIL4DotNet.Tests/Runner/PluginLoaderTests.cs), [`FileDiffServiceUnitTests.Hooks`](../FolderDiffIL4DotNet.Tests/Services/FileDiffServiceUnitTests.Hooks.cs), [`DotNetDisassemblerProviderTests`](../FolderDiffIL4DotNet.Tests/Services/DotNetDisassemblerProviderTests.cs), [`PluginConfigSettingsTests`](../FolderDiffIL4DotNet.Tests/Models/PluginConfigSettingsTests.cs), [`ReportFormatterTests`](../FolderDiffIL4DotNet.Tests/Services/ReportFormatterTests.cs), [`ReportSectionWriterOrderTests`](../FolderDiffIL4DotNet.Tests/Services/ReportSectionWriterOrderTests.cs) | サーチパスからのプラグイン読み込み(存在しないパス、空ディレクトリ、無効な DLL、同一プラグイン DLL の重複サーチパス抑止、コンストラクタ null ガード、trusted hash 未設定/空/不一致時の strict mode 事前ハッシュ拒否)、`IFileComparisonHook` の before/after 統合(フックオーバーライド、null パススルー、例外処理、フェーズ付き失敗ログと hook order、マルチフック順序付け)、`DotNetDisassemblerProvider` の .NET/非 .NET ファイル向け CanHandle、recoverable な判定失敗時の DisplayName/拡張子付き warning、DisassembleAsync 委譲とエラー処理、プラグイン設定のラウンドトリップ(PluginSearchPaths、PluginEnabledIds、PluginConfig JSON シリアライズ、読取専用保証)、`IReportFormatter` の Order 値と IsEnabled 条件、`IReportSectionWriter` 組み込みライター数と一意/昇順 Order 不変条件 | | Runner 層 | [`CliOverrideApplierTests`](../FolderDiffIL4DotNet.Tests/Runner/CliOverrideApplierTests.cs)、[`DiffPipelineExecutorTests`](../FolderDiffIL4DotNet.Tests/Runner/DiffPipelineExecutorTests.cs)、[`DryRunExecutorTests`](../FolderDiffIL4DotNet.Tests/Runner/DryRunExecutorTests.cs)、[`RunScopeBuilderTests`](../FolderDiffIL4DotNet.Tests/Runner/RunScopeBuilderTests.cs)、[`PluginAssemblyLoadContextTests`](../FolderDiffIL4DotNet.Tests/Runner/PluginAssemblyLoadContextTests.cs)、[`SpinnerThemesTests`](../FolderDiffIL4DotNet.Tests/Runner/SpinnerThemesTests.cs) | CLI オーバーライド適用(`--threads`、`--no-il-cache`、`--skip-il`、`--no-timestamp-warnings` のビルダーへの反映、デフォルトオプションはビルダーを変更しないこと)、`FormatElapsedTime` フォーマット(ゼロ、秒未満、複数時間)、`DiffPipelineResult` レコード等価性(`HasILFilterWarnings` フィールドを含む)、コンストラクタ null ガード、`RunScopeBuilder.BuildExecutionContext` ネットワーク検出とパス保存、`CreateIlCache` 条件付き生成、DI コンテナサービス解決、`PluginAssemblyLoadContext` コレクティブルコンテキスト生成と共有アセンブリフォールバック、ポストプロセスアクション失敗が best-effort のまま継続されつつ Warning ログにアクション型と例外型が残ること、非表示のスピナーイースターエッグ互換動作 | -| セクションライター個別 | [`HeaderSectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/HeaderSectionWriterTests.cs)、[`LegendSectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/LegendSectionWriterTests.cs)、[`ConditionalSectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/ConditionalSectionWriterTests.cs)、[`FileListSectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/FileListSectionWriterTests.cs)、[`SummarySectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/SummarySectionWriterTests.cs)、[`IgnoredFilesSectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/IgnoredFilesSectionWriterTests.cs)、[`ILCacheStatsSectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/ILCacheStatsSectionWriterTests.cs)、[`WarningsSectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/WarningsSectionWriterTests.cs) | ライターごとの Order 値、IsEnabled 条件(IgnoredFiles 設定フラグ、UnchangedFiles 設定フラグ、ILCacheStats 設定+キャッシュ null、Warnings SHA256 不一致/タイムスタンプ後退/IL フィルタ文字列検証)、Write 出力内容アサーション(レポートタイトル、アプリバージョン、パス、コンピュータ名、凡例 SHA256、Added/Removed/Modified/Unchanged セクションヘッダ、サマリー経過時間、警告キーワード、IL フィルタ警告テキスト)、空/ゼロカウント結果リスト処理、Ignored 空リスト時の早期リターン、ILCacheStats キャッシュ null 時の無効化 | +| セクションライター個別 | [`HeaderSectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/HeaderSectionWriterTests.cs)、[`LegendSectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/LegendSectionWriterTests.cs)、[`ConditionalSectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/ConditionalSectionWriterTests.cs)、[`FileListSectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/FileListSectionWriterTests.cs)、[`SummarySectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/SummarySectionWriterTests.cs)、[`IgnoredFilesSectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/IgnoredFilesSectionWriterTests.cs)、[`ILCacheStatsSectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/ILCacheStatsSectionWriterTests.cs)、[`WarningsSectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/WarningsSectionWriterTests.cs) | ライターごとの Order 値、IsEnabled 条件(IgnoredFiles 設定フラグ、UnchangedFiles 設定フラグ、ILCacheStats 設定+キャッシュ null、Warnings SHA256 不一致/タイムスタンプ後退/IL 部分文字列設定の安全性)、Write 出力内容アサーション(レポートタイトル、アプリバージョン、パス、コンピュータ名、凡例 SHA256、Added/Removed/Modified/Unchanged セクションヘッダ、サマリー経過時間、警告キーワード、IL 部分文字列設定の安全性警告テキスト)、空/ゼロカウント結果リスト処理、Ignored 空リスト時の早期リターン、ILCacheStats キャッシュ null 時の無効化 | | プラグインアンロード/クリーンアップ | [`PluginUnloadCleanupTests`](../FolderDiffIL4DotNet.Tests/Runner/PluginUnloadCleanupTests.cs) | コレクティブル `AssemblyLoadContext` のアンロード(クリーンアンロード、アセンブリ読み込み後のアンロード、複数コンテキストの独立アンロード)、`WeakReference` によるアンロード後 GC 回収検証、二重アンロード `InvalidOperationException`、`Unloading` イベント発火、共有 `Plugin.Abstractions` アセンブリのプラグインアンロード後存続 | | セキュリティ(XSS/パストラバーサル) | [`HtmlReportSecurityTests`](../FolderDiffIL4DotNet.Tests/Security/HtmlReportSecurityTests.cs)、[`PathTraversalSecurityTests`](../FolderDiffIL4DotNet.Tests/Security/PathTraversalSecurityTests.cs) | `HtmlEncode` XSS 防止(script タグ、属性インジェクション、テンプレートリテラルのバックティック、二重エンコード、制御文字、CJK テキスト透過、悪意あるファイルパス、HTML 特殊文字 5 種、null/空文字列)、`PathValidator` トラバーサル防止(相対パスエスケープ、dot/dotdot、NULL バイト/制御文字、Windows 予約名、末尾スペース/ドット、絶対パス、Unicode 受入、極端なパス長) | | ネットワーク共有最適化 | [`NetworkPathDetectorTests`](../FolderDiffIL4DotNet.Tests/Core/IO/NetworkPathDetectorTests.cs) | `NetworkPathDetector.IsLikelyNetworkPath` UNC 検出(バックスラッシュ、フォワードスラッシュ、デバイスパス形式)、null/空/空白の拒否、ローカルパス拒否。同一ファイル内で `DiffExecutionContext` のネットワークフラグ伝播(`OptimizeForNetworkShares`、`DetectedNetworkOld/New`)、null パス `ArgumentNullException`、IL 出力パス導出も検証 | diff --git a/doc/TROUBLESHOOTING.md b/doc/TROUBLESHOOTING.md index 1a4ff9ba..2fe87ff2 100644 --- a/doc/TROUBLESHOOTING.md +++ b/doc/TROUBLESHOOTING.md @@ -40,12 +40,12 @@ If this fails, install the .NET SDK first from ; a numeric suffix is used if needed. + // Leading/trailing whitespace is part of the exact match; whitespace-only values are ignored. + // The --creator profile prepends its build-machine and temporary-path values before this list. + // The combined configured/profile list is limited to 256 entries of up to 4096 Unicode characters each; profile and duplicate entries count toward the limit. + // IL 行の一致部分を比較ペア内で衝突しないマーカーへ置換する。 + // 通常は を使い、必要な場合は数値 suffix を付ける。 + // 前後空白は正確な一致条件の一部として保持し、空白だけの値は無視する。 + // --creator プロファイルはビルドマシン名と一時パスの値をこのリストより前へ追加する。 + // 設定値とプロファイル値の結合後は最大 256 件、各値は最大 4096 Unicode 文字。プロファイル値と重複値も件数上限に含む。 + "ILNormalizeContainingStrings": [], + // --- File Extensions / ファイル拡張子 --- // Extensions to skip entirely (not compared, listed as Ignored) (defaults: .cache, .DS_Store, .db, .ilcache, .log, .pdb) diff --git a/doc/config.schema.json b/doc/config.schema.json index 6fcd4361..62c03457 100644 --- a/doc/config.schema.json +++ b/doc/config.schema.json @@ -113,11 +113,6 @@ "default": true, "description": "Output full IL disassembly text under the active reports root in /