From 58cd75dda097b0e59d023cd28ce3d7a2138b6660 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 15 Aug 2026 00:33:13 +0900 Subject: [PATCH 1/8] Add real disassembler IL corpus --- CHANGELOG.md | 8 + .../Fixtures/ILCorpus/README.md | 55 + .../Fixtures/ILCorpus/Source/AssemblyInfo.cs | 9 + .../ILCorpus/Source/ILCorpus.Sample.csproj | 15 + .../ILCorpus/Source/ILCorpus.TestKey.snk | Bin 0 -> 1172 bytes .../ILCorpus/Source/RepresentativeTypes.cs | 97 ++ .../Fixtures/ILCorpus/dotnet-ildasm-0.12.2.il | 641 ++++++++++++ .../Fixtures/ILCorpus/ilspycmd-9.1.0.7988.il | 979 ++++++++++++++++++ .../Fixtures/ILCorpus/regenerate.ps1 | 47 + .../FolderDiffIL4DotNet.Tests.csproj | 1 + .../Services/ILOutputCorpusTests.cs | 120 +++ 11 files changed, 1972 insertions(+) create mode 100644 FolderDiffIL4DotNet.Tests/Fixtures/ILCorpus/README.md create mode 100644 FolderDiffIL4DotNet.Tests/Fixtures/ILCorpus/Source/AssemblyInfo.cs create mode 100644 FolderDiffIL4DotNet.Tests/Fixtures/ILCorpus/Source/ILCorpus.Sample.csproj create mode 100644 FolderDiffIL4DotNet.Tests/Fixtures/ILCorpus/Source/ILCorpus.TestKey.snk create mode 100644 FolderDiffIL4DotNet.Tests/Fixtures/ILCorpus/Source/RepresentativeTypes.cs create mode 100644 FolderDiffIL4DotNet.Tests/Fixtures/ILCorpus/dotnet-ildasm-0.12.2.il create mode 100644 FolderDiffIL4DotNet.Tests/Fixtures/ILCorpus/ilspycmd-9.1.0.7988.il create mode 100644 FolderDiffIL4DotNet.Tests/Fixtures/ILCorpus/regenerate.ps1 create mode 100644 FolderDiffIL4DotNet.Tests/Services/ILOutputCorpusTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 72df58dd..b59f1bcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### [Unreleased] +#### Added + +- **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`). + ### [1.24.0] - 2026-07-29 #### Added @@ -1719,6 +1723,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### [Unreleased] +#### 追加 + +- **実逆アセンブラ 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`)が必要です。 + ### [1.24.0] - 2026-07-29 #### 追加 diff --git a/FolderDiffIL4DotNet.Tests/Fixtures/ILCorpus/README.md b/FolderDiffIL4DotNet.Tests/Fixtures/ILCorpus/README.md new file mode 100644 index 00000000..755a4d52 --- /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` 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 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` 本体の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にできないためです。代わりにソースから再現できる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 0000000000000000000000000000000000000000..9838f6e748197975b6ef109d6a67d1932f72e8aa GIT binary patch literal 1172 zcmV;F1Z(>T0ssI2qyPX?Q$aES2mk;90098u4aJ65GWZo?tSOitDrgjDaa0U|E3%ZY zvrWKd&t0BRR|AHpJy&*qB8l1;fTbw4KDRMbM=NFLerjM{y+SJEFp~@U5R)qrid+Tp zUvcEeX^VIy2}i3#9F3FeI1e`&?2SFFZz8=OiD=1jC)!3Zls?XKHhT$?A^60Q%indsCeh~Ha>ShF){ZPne zze3Pu)Gwp73UJ;mtwUpd>oTjev&wD|IKp@;HFOtgsp10y)k+BIb+k@0>b52%69 zS^Pm-i&l$H)_!qvjpI6xcrqUyMVC_>Z#!bTh*1vc@BacD2Gs00?Qhir#*qdrk%$K& zub3d34e{KCAvu<;>}eJdF(i*|VMq~=WGNU`|>(!Fq;J?OMyhT z!p<&E^DSPZeYfMZ`(oJnw1|+9_Q^%cr#UCgEVSV^{G-XEc`fivOwvC%N)esbtBO0N zdMmqaHKCvig%86`6yEz!YA)cmEnA)`7|UW?o5^_Qryj_W(sQuukF#UzfMCHt8@$T~ zJ@Y;hc(*fyaKvHw7i@gYsZz-yx(wNa1fYG^w{OC{gvrEauYWl^n{<$4F=kGWGL78M zlGwa55iCxL>`aTr-kf@)`f_nyuQN$Jwh-B1!1-AxF)J=zU*_~nETeC(RB0k`p#cgy4`-6^h|aBe(@v5reoz z>Vt3lbK{uqB1Wb0omYGdKAF literal 0 HcmV?d00001 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..0e59c45d --- /dev/null +++ b/FolderDiffIL4DotNet.Tests/Fixtures/ILCorpus/regenerate.ps1 @@ -0,0 +1,47 @@ +[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" + +$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." +} + +$previousRollForward = $env:DOTNET_ROLL_FORWARD +try { + $env:DOTNET_ROLL_FORWARD = "Major" + dotnet-ildasm $assemblyPath --output $dotnetIldasmOutput --force + if ($LASTEXITCODE -ne 0) { + throw "dotnet-ildasm failed." + } +} +finally { + $env:DOTNET_ROLL_FORWARD = $previousRollForward +} + +& ilspycmd --ilcode --disable-updatecheck $assemblyPath | + Set-Content -LiteralPath $ilspyOutput -Encoding utf8 +if ($LASTEXITCODE -ne 0) { + throw "ilspycmd failed." +} + +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/Services/ILOutputCorpusTests.cs b/FolderDiffIL4DotNet.Tests/Services/ILOutputCorpusTests.cs new file mode 100644 index 00000000..05e25386 --- /dev/null +++ b/FolderDiffIL4DotNet.Tests/Services/ILOutputCorpusTests.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; +using System.IO; +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_ContainsItsActualCommentAndAttributeSyntax() + { + var text = ReadFixture("dotnet-ildasm-0.12.2.il"); + + Assert.Contains("// MVID: {", text, StringComparison.Ordinal); + Assert.Contains("// Method begins at Relative Virtual Address (RVA) 0x", text, StringComparison.Ordinal); + Assert.Contains("// Code size ", text, StringComparison.Ordinal); + Assert.Contains(".custom instance void class [System.Runtime]", text, StringComparison.Ordinal); + } + + [Fact] + public void IlspyFixture_ContainsItsActualCommentAndAttributeSyntax() + { + var text = ReadFixture("ilspycmd-9.1.0.7988.il"); + + 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("// Method begins at Relative Virtual Address (RVA) 0x", text, StringComparison.Ordinal); + } + + private static string ReadFixture(string fixtureFileName) + => File.ReadAllText(Path.Combine(CorpusRoot, fixtureFileName)); + + 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."); + } + } +} From 39106a1d47b3cfb9294bd8e98de30968daa560e7 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 15 Aug 2026 01:05:13 +0900 Subject: [PATCH 2/8] Normalize build-variant IL values --- CHANGELOG.md | 22 + Common/Constants.cs | 43 +- .../Fixtures/ILCorpus/README.md | 8 +- .../Models/ConfigSettingsTests.cs | 10 + .../ProgramRunnerTests.CliOverrides.cs | 5 +- .../Runner/CliOverrideApplierTests.cs | 5 +- .../ConfigServiceTests.EnvVarsAndMutation.cs | 17 + .../Services/DisassemblerHelperTests.cs | 3 +- .../Services/FolderDiffServiceUnitTests.cs | 134 ++++++ ...tmlReportGenerateServiceTests.Filtering.cs | 11 +- ...ortGenerateServiceTests.MutationKilling.cs | 80 +++- .../Services/ILOutputCorpusTests.cs | 43 +- .../ILOutputServiceTests.Precompute.cs | 16 +- .../Services/ILOutputServiceTests.cs | 354 ++++++++++++---- .../ReportGenerateServiceTests.Header.cs | 161 +++++-- Models/ConfigSettings.ILSettings.cs | 25 +- Models/ConfigSettings.cs | 4 +- Models/ConfigSettingsBuilder.ILSettings.cs | 14 +- Models/IReadOnlyConfigSettings.cs | 9 +- Runner/creator_il_ignore_profiles.json | 4 - Services/ConfigService.cs | 1 + Services/DisassemblerHelper.cs | 6 +- Services/DotNetDisassembleService.cs | 2 +- Services/FolderDiffService.cs | 18 +- .../HtmlReportGenerateService.Helpers.cs | 10 - .../HtmlReportGenerateService.Sections.cs | 70 ++- Services/HtmlReport/diff_report.css | 5 + Services/ILConfiguredSubstringHelper.cs | 39 ++ Services/ILOutput/IILTextOutputService.cs | 12 +- Services/ILOutput/ILTextOutputService.cs | 6 +- Services/ILOutputService.Comparison.cs | 399 +++++++++++++++--- Services/ILOutputService.cs | 45 +- Services/ReportGenerateService.Helpers.cs | 14 - Services/ReportGenerateService.cs | 2 - .../SectionWriters/HeaderSectionWriter.cs | 99 ++++- USER_GUIDE.md | 72 +++- doc/DEVELOPER_GUIDE.md | 34 +- doc/TESTING_GUIDE.md | 10 +- doc/TROUBLESHOOTING.md | 8 +- doc/config.sample.jsonc | 19 +- doc/config.schema.json | 16 +- doc/samples/diff_report.html | 53 ++- doc/samples/diff_report.md | 49 ++- 43 files changed, 1551 insertions(+), 406 deletions(-) create mode 100644 Services/ILConfiguredSubstringHelper.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index b59f1bcb..beafa585 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), #### Added +- **Configurable IL substring normalization** — Added `ShouldILNormalizeContainingConfiguredStrings` and `ILNormalizeContainingStrings`. When enabled, every occurrence of each configured substring is replaced with ``, while all surrounding text on the same IL line remains comparable. 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. 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`). +#### Changed + +- **Build-variant IL values are normalized instead of discarded** — MVID, method RVA, code-size, and WinForms `AxHost.TypeLibraryTimeStampAttribute` values are now 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 ignore defaults preserve more meaningful differences** — Removed the built-in RVA, code-size, WinForms `AxHost.TypeLibraryTimeStampAttribute`, and `.publickeytoken = (` entries from the `buildserver-winforms` ignore profile. The first three are now handled by built-in normalization in every mode. Public key token changes are no longer suppressed, so AssemblyRef token differences 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. Guidance clarifies sequential replacement order, why the current built-in 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. + +#### Removed + +- **`ShouldIgnoreMVID` configuration setting** — Removed as a breaking configuration change. MVID values are now always normalized to `` instead of conditionally removing or comparing the complete MVID line. + ### [1.24.0] - 2026-07-29 #### Added @@ -1725,8 +1736,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), #### 追加 +- **設定可能な IL 部分文字列正規化** — `ShouldILNormalizeContainingConfiguredStrings` と `ILNormalizeContainingStrings` を追加しました。有効時は、設定した各部分文字列のすべての出現箇所を `` へ置換し、同じIL行にある周辺テキストは比較可能な状態で残します。設定正規化と設定行除外はどちらも、`ldstr` が読み込むプログラム文字列リテラルを含む、すべての IL 命令文脈へ適用します。空でない正規化値の前後空白は正確な一致条件の一部として保持し、空白だけの値は無視します。これは、一致した行全体を比較から除外する従来の `ILIgnoreLineContainingStrings` とは独立した機能です。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`)が必要です。 +#### 変更 + +- **ビルド依存 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` 無視プロファイルから、RVA、code-size、WinForms `AxHost.TypeLibraryTimeStampAttribute`、`.publickeytoken = (` の4エントリを除去しました。最初の3つは全モードの組み込み正規化で処理します。public key token変更は抑制しなくなったため、AssemblyRefのtoken差分はcreatorモードでも残ります。 +- **正規化レポートとツール案内を明確化** — Markdown/HTMLレポートには、組み込み正規化規則、設定正規化部分文字列、設定無視部分文字列の順で表示します。組み込み表と設定正規化表には、行の並びが適用順であることを明記します。表では `Line Prefix Pattern`、`Substring to Normalize`、`Substring to Ignore` を区別し、設定値へ装飾的なダブルクオートを付けません。有効だが実効値が0件の場合は空の表を出さず、設定値がないことだけを明示します。組み込み表には各パターンの置換マーカーと確認元逆アセンブラも表示します。重複または包含関係にある正規化値はコンソール/レポート警告の対象にしました。逐次置換の適用順、現行の組み込み規則で順序が結果へ影響しない理由、`nildiff` 本体が `net8.0` をtargetとすること、`ilspycmd 9.1.0.7988` を.NET 8/9/10をtarget frameworkとするAssembly調査の固定fallback baselineとすることを案内に明記しました。英日両方の設定文書、schema、guide、レポートサンプルを新しい挙動へ揃えました。 + +#### 削除 + +- **`ShouldIgnoreMVID` 設定** — 破壊的な設定変更として削除しました。MVID値は、完全なMVID行を条件付きで除外または比較するのではなく、常に `` へ正規化します。 + ### [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/Fixtures/ILCorpus/README.md b/FolderDiffIL4DotNet.Tests/Fixtures/ILCorpus/README.md index 755a4d52..7a3c1156 100644 --- a/FolderDiffIL4DotNet.Tests/Fixtures/ILCorpus/README.md +++ b/FolderDiffIL4DotNet.Tests/Fixtures/ILCorpus/README.md @@ -9,13 +9,13 @@ This directory contains golden IL emitted from the same small, deterministic tes 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. +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 source-level COM interface and coclass metadata are included instead. +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: @@ -36,13 +36,13 @@ Review fixture changes before committing them. Disassembler upgrades intentional サンプルには、通常の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は含まないことも固定しています。 +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にできないためです。代わりにソースから再現できるCOM interfaceとcoclass metadataを含めています。 +ActiveX wrapper出力は含めていません。`AxHost.TypeLibraryTimeStampAttribute` の再現には、.NET SDKに含まれないWindows固有の `aximp` 入力とツールが必要で、portableかつ再現可能なfixtureにできないためです。型固有の正規化テストでは、コミット済みfixtureで確認した各逆アセンブラのcustom attribute文法を使い、ilspycmdの複数行byte blobを閉じ `)` まで再現します。代わりにソースから再現できるCOM interfaceとcoclass metadataを含めています。 リポジトリルートから、次のコマンドで両方のファイルを再生成します。 diff --git a/FolderDiffIL4DotNet.Tests/Models/ConfigSettingsTests.cs b/FolderDiffIL4DotNet.Tests/Models/ConfigSettingsTests.cs index db6510d7..203eea19 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); } @@ -357,6 +364,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..7938d3e9 100644 --- a/FolderDiffIL4DotNet.Tests/ProgramRunnerTests.CliOverrides.cs +++ b/FolderDiffIL4DotNet.Tests/ProgramRunnerTests.CliOverrides.cs @@ -686,7 +686,10 @@ await WithConfigFileAsync(configJson, async () => Assert.Contains("\"ShouldIgnoreILLinesContainingConfiguredStrings\": true", output, StringComparison.Ordinal); Assert.Contains("existing-filter", output, StringComparison.Ordinal); Assert.Contains("buildserver1_", output, StringComparison.Ordinal); - Assert.Contains("// Code size ", output, StringComparison.Ordinal); + Assert.DoesNotContain("// Method begins at Relative Virtual Address (RVA) 0x", output, StringComparison.Ordinal); + Assert.DoesNotContain(".publickeytoken = ( ", output, StringComparison.Ordinal); + Assert.DoesNotContain("TypeLibraryTimeStampAttribute", output, 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); }); diff --git a/FolderDiffIL4DotNet.Tests/Runner/CliOverrideApplierTests.cs b/FolderDiffIL4DotNet.Tests/Runner/CliOverrideApplierTests.cs index feba0ed5..3e135c56 100644 --- a/FolderDiffIL4DotNet.Tests/Runner/CliOverrideApplierTests.cs +++ b/FolderDiffIL4DotNet.Tests/Runner/CliOverrideApplierTests.cs @@ -113,7 +113,10 @@ public void Apply_CreatorIlIgnoreProfile_EnablesFilteringAndMergesStrings() Assert.True(builder.ShouldIgnoreILLinesContainingConfiguredStrings); Assert.Contains("existing-filter", builder.ILIgnoreLineContainingStrings); Assert.Contains("buildserver1_", builder.ILIgnoreLineContainingStrings); - Assert.Contains("// Code size ", builder.ILIgnoreLineContainingStrings); + Assert.DoesNotContain("// Method begins at Relative Virtual Address (RVA) 0x", builder.ILIgnoreLineContainingStrings); + Assert.DoesNotContain(".publickeytoken = ( ", builder.ILIgnoreLineContainingStrings); + Assert.DoesNotContain(".custom instance void class [System.Windows.Forms]System.Windows.Forms.AxHost/TypeLibraryTimeStampAttribute::.ctor(string) = ( ", builder.ILIgnoreLineContainingStrings); + Assert.DoesNotContain("// Code size ", builder.ILIgnoreLineContainingStrings); Assert.Contains("¥temp¥develop¥", builder.ILIgnoreLineContainingStrings); Assert.Contains("¥¥temp¥¥develop¥¥", builder.ILIgnoreLineContainingStrings); Assert.Contains(@"\temp\develop\", builder.ILIgnoreLineContainingStrings); diff --git a/FolderDiffIL4DotNet.Tests/Services/ConfigServiceTests.EnvVarsAndMutation.cs b/FolderDiffIL4DotNet.Tests/Services/ConfigServiceTests.EnvVarsAndMutation.cs index 95cac660..14d0f1ac 100644 --- a/FolderDiffIL4DotNet.Tests/Services/ConfigServiceTests.EnvVarsAndMutation.cs +++ b/FolderDiffIL4DotNet.Tests/Services/ConfigServiceTests.EnvVarsAndMutation.cs @@ -84,6 +84,23 @@ 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_EnvVarOverridesStringProperty_AppliesOverride() { 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..1461345f 100644 --- a/FolderDiffIL4DotNet.Tests/Services/FolderDiffServiceUnitTests.cs +++ b/FolderDiffIL4DotNet.Tests/Services/FolderDiffServiceUnitTests.cs @@ -117,6 +117,140 @@ 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_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..5959684e 100644 --- a/FolderDiffIL4DotNet.Tests/Services/HtmlReportGenerateServiceTests.MutationKilling.cs +++ b/FolderDiffIL4DotNet.Tests/Services/HtmlReportGenerateServiceTests.MutationKilling.cs @@ -384,10 +384,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 +406,80 @@ 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("All listed rules are applied to every IL text", html); + Assert.Contains($"produced it.{Environment.NewLine}
When a line matches", html); + Assert.Contains($"stable marker.
{Environment.NewLine}
For ilspycmd's multiline TypeLibraryTimeStampAttribute", html); + Assert.Contains($"one stable marker.
{Environment.NewLine}
Disassembler (Observed In)", html); + Assert.Contains("Disassembler (Observed In)", html); + Assert.Contains("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.Contains("<nildiff:normalized:configured-value>", html); + Assert.Contains("all remaining text on the line stays unchanged and comparable", html); + Assert.Equal(2, html.Split("listed in application order", StringSplitOptions.None).Length - 1); + 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 index 05e25386..79f9b85d 100644 --- a/FolderDiffIL4DotNet.Tests/Services/ILOutputCorpusTests.cs +++ b/FolderDiffIL4DotNet.Tests/Services/ILOutputCorpusTests.cs @@ -1,6 +1,9 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; +using FolderDiffIL4DotNet.Common; +using FolderDiffIL4DotNet.Services; using Xunit; namespace FolderDiffIL4DotNet.Tests.Services @@ -75,32 +78,52 @@ public void DotNetIldasmFixture_ContainsSignedAssemblyAndAssemblyReferenceEviden } [Fact] - public void DotNetIldasmFixture_ContainsItsActualCommentAndAttributeSyntax() + public void DotNetIldasmFixture_BuiltInNormalizationUsesRealOutputLines() { - var text = ReadFixture("dotnet-ildasm-0.12.2.il"); - - Assert.Contains("// MVID: {", text, StringComparison.Ordinal); - Assert.Contains("// Method begins at Relative Virtual Address (RVA) 0x", text, StringComparison.Ordinal); - Assert.Contains("// Code size ", text, StringComparison.Ordinal); - Assert.Contains(".custom instance void class [System.Runtime]", text, StringComparison.Ordinal); + 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_ContainsItsActualCommentAndAttributeSyntax() + public void IlspyFixture_BuiltInNormalizationUsesItsActualCommentSyntax() { - var text = ReadFixture("ilspycmd-9.1.0.7988.il"); + 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("// Method begins at Relative Virtual Address (RVA) 0x", 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; diff --git a/FolderDiffIL4DotNet.Tests/Services/ILOutputServiceTests.Precompute.cs b/FolderDiffIL4DotNet.Tests/Services/ILOutputServiceTests.Precompute.cs index 9f2c8d6b..129fc891 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(); @@ -228,7 +226,7 @@ public async Task DiffDotNetAssembliesAsync_WhenFilteredIlSideIsEmpty_LogsWithou 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 +236,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"); } diff --git a/FolderDiffIL4DotNet.Tests/Services/ILOutputServiceTests.cs b/FolderDiffIL4DotNet.Tests/Services/ILOutputServiceTests.cs index 8af16b9e..f7861211 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,184 @@ 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); + } + + [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 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 +301,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 +318,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 +383,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 +398,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 +410,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 +422,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 +435,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 +447,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 +457,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 +670,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 +708,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 +718,73 @@ 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_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)); + } + + [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 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 +793,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 +865,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 +896,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 +908,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..7b7f0d0d 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,22 @@ 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("All listed rules are applied to every IL text", reportText); + Assert.Contains($"produced it.{Environment.NewLine}{Environment.NewLine}When a line matches", reportText); + Assert.Contains($"stable marker.{Environment.NewLine}{Environment.NewLine}For ilspycmd's multiline TypeLibraryTimeStampAttribute", reportText); + Assert.Contains($"one stable marker.{Environment.NewLine}{Environment.NewLine}**Disassembler (Observed In)**", reportText); + Assert.Contains("Disassembler (Observed In)", reportText); + Assert.Contains("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 +190,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 |", reportText); + Assert.Contains("| buildserver |", reportText); + Assert.Contains("| buildPath |", reportText); + Assert.DoesNotContain("| \"buildserver\" |", reportText); } [Fact] @@ -221,7 +219,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 +237,119 @@ 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("", reportText); + Assert.Contains("all remaining text on the line stays unchanged and comparable", reportText); + Assert.Equal(2, reportText.Split("listed in application order", StringSplitOptions.None).Length - 1); + 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_HeaderEscapesConfiguredStringsInMarkdownTables() + { + 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" + }; + 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("| normalize|value |", reportText); + Assert.Contains("| line1
line2 |", reportText); + Assert.Contains("| padded |", reportText); + Assert.Contains("| tabbed |", reportText); + Assert.Contains("| ignore|value |", 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 +444,3 @@ public void GenerateDiffReport_NoMixedWarning_WhenSingleDisassemblerUsed() } } } - diff --git a/Models/ConfigSettings.ILSettings.cs b/Models/ConfigSettings.ILSettings.cs index 6c24cda6..d59f7b04 100644 --- a/Models/ConfigSettings.ILSettings.cs +++ b/Models/ConfigSettings.ILSettings.cs @@ -11,10 +11,10 @@ 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; // ── IL cache defaults / IL キャッシュデフォルト ─────────────────────── /// Default value for . / の既定値。 @@ -57,19 +57,22 @@ 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. + /// IL 比較時に正確な一致部分を正規化する文字列リスト。前後空白も一致条件に含みます。 /// - public bool ShouldIgnoreMVID { get; } + public IReadOnlyList ILNormalizeContainingStrings { get; } + + /// + /// 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/IReadOnlyConfigSettings.cs b/Models/IReadOnlyConfigSettings.cs index f8705f78..39624b91 100644 --- a/Models/IReadOnlyConfigSettings.cs +++ b/Models/IReadOnlyConfigSettings.cs @@ -82,12 +82,15 @@ 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. / IL 比較時に正確な一致部分を正規化する文字列リスト。前後空白も一致条件に含みます。 + 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/Runner/creator_il_ignore_profiles.json b/Runner/creator_il_ignore_profiles.json index 7bb0d701..c9ff2e17 100644 --- a/Runner/creator_il_ignore_profiles.json +++ b/Runner/creator_il_ignore_profiles.json @@ -5,10 +5,6 @@ "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..1b7a97a8 100644 --- a/Services/ConfigService.cs +++ b/Services/ConfigService.cs @@ -164,6 +164,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/FolderDiffService.cs b/Services/FolderDiffService.cs index d62f2227..8ffd7cce 100644 --- a/Services/FolderDiffService.cs +++ b/Services/FolderDiffService.cs @@ -164,7 +164,7 @@ public async Task ExecuteFolderDiffAsync(CancellationToken cancellationToken = d currentPhase = "scanning assembly candidates"; ScanAssemblyCandidatesAndLog(); currentPhase = "validating IL filter strings"; - ValidateILFilterStrings(); + ValidateILConfiguredStrings(); currentPhase = "precomputing IL cache"; await PrecomputeIlCachesAsync(maxParallel.Value, cancellationToken); @@ -446,19 +446,11 @@ private void ProcessAddedFiles(IEnumerable remainingNewFiles, int proces /// 設定された 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 = ILOutputService.ValidateILNormalizeContainingStrings(_config.ILNormalizeContainingStrings); + 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..fae37418 100644 --- a/Services/HtmlReport/HtmlReportGenerateService.Sections.cs +++ b/Services/HtmlReport/HtmlReportGenerateService.Sections.cs @@ -51,34 +51,66 @@ 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("All listed rules are applied to every IL text, regardless of which disassembler produced it.")}
"); + writer.WriteLine($"
{HtmlEncode("When a line matches, the listed prefix is preserved and only the build-variant value that follows it is replaced with the corresponding stable marker.")}
"); + writer.WriteLine($"
{HtmlEncode("For ilspycmd's multiline TypeLibraryTimeStampAttribute form, the header prefix is preserved and the complete blob through its closing ) is replaced with one stable marker.")}
"); + writer.WriteLine($"
{HtmlEncode("Disassembler (Observed In) identifies the disassembler whose real output established the pattern; it does not limit where the rule is applied.")}
"); + writer.WriteLine($"
{HtmlEncode("The rules are listed in application order.")}
"); + 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("Disassembler (Observed In)")}
{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($"Every occurrence of each configured substring is replaced with {ILOutputService.CONFIGURED_NORMALIZED_VALUE}; all remaining text on the line stays unchanged and comparable.")}
"); + writer.WriteLine($"
{HtmlEncode("The substrings are listed in application order.")}
"); + 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) { 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..88f1a721 --- /dev/null +++ b/Services/ILConfiguredSubstringHelper.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +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) + { + 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..a02a9c72 100644 --- a/Services/ILOutput/IILTextOutputService.cs +++ b/Services/ILOutput/IILTextOutputService.cs @@ -14,8 +14,14 @@ public interface IILTextOutputService /// MVID を除外した 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..f3017b9d 100644 --- a/Services/ILOutput/ILTextOutputService.cs +++ b/Services/ILOutput/ILTextOutputService.cs @@ -39,7 +39,7 @@ 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 に出力(除外行適用後)し、読み取り専用化します。 /// - 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 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, @@ -30,26 +53,29 @@ internal static bool StreamingFilteredSequenceEqual( IReadOnlyList lines2, bool shouldIgnoreContainingStrings, IReadOnlyCollection ilIgnoreContainingStrings, - bool shouldIgnoreMVID = true) + IReadOnlyCollection ilNormalizeContainingStrings) { 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 end1 = i >= count1; - bool end2 = j >= count2; + bool hasLine1 = TryReadNextFilteredNormalizedLine( + lines1, + ref i, + shouldIgnoreContainingStrings, + ilIgnoreContainingStrings, + ilNormalizeContainingStrings, + out string normalized1); + bool hasLine2 = TryReadNextFilteredNormalizedLine( + lines2, + ref j, + shouldIgnoreContainingStrings, + ilIgnoreContainingStrings, + ilNormalizeContainingStrings, + out string normalized2); + + bool end1 = !hasLine1; + bool end2 = !hasLine2; if (end1 && end2) { return true; @@ -64,13 +90,10 @@ 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++; } } @@ -84,18 +107,149 @@ internal static List FilterIlLines( IReadOnlyList lines, bool shouldIgnoreContainingStrings, IReadOnlyCollection ilIgnoreContainingStrings, - bool shouldIgnoreMVID = true) + IReadOnlyCollection ilNormalizeContainingStrings) { 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, + out string normalizedLine)) { - if (!ShouldExcludeIlLine(lines[i], shouldIgnoreContainingStrings, ilIgnoreContainingStrings, shouldIgnoreMVID)) + result.Add(normalizedLine); + } + + return result; + } + + private static bool TryReadNextFilteredNormalizedLine( + IReadOnlyList lines, + ref int lineIndex, + bool shouldIgnoreContainingStrings, + IReadOnlyCollection ilIgnoreContainingStrings, + IReadOnlyCollection ilNormalizeContainingStrings, + out string normalizedLine) + { + while (lineIndex < lines.Count) + { + 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); + 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; } /// @@ -108,9 +262,10 @@ 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 +283,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 +350,66 @@ 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; + } + + return ilIgnoreContainingStrings.Any(target => line.Contains(target, StringComparison.Ordinal)); + } + + /// + /// Replaces known build-variant IL values and configured matching substrings with stable placeholders. + /// 既知のビルド依存 IL 値と設定された一致部分を安定したプレースホルダーへ置換します。 + /// + internal static string NormalizeIlLine( + string line, + IReadOnlyCollection ilNormalizeContainingStrings) + { + if (line is null) + { + return string.Empty; } - if (!shouldIgnoreContainingStrings || ilIgnoreContainingStrings == null || ilIgnoreContainingStrings.Count == 0) + string normalized = line; + foreach (var rule in BuiltInNormalizationRules) { - return false; + normalized = NormalizeSuffixAfterPrefix(normalized, rule.Prefix, rule.Replacement); } - return ilIgnoreContainingStrings.Any(target => line.Contains(target, StringComparison.Ordinal)); + if (ilNormalizeContainingStrings.Count == 0) + { + return normalized; + } + + foreach (string target in ilNormalizeContainingStrings) + { + if (!string.IsNullOrWhiteSpace(target)) + { + normalized = normalized.Replace(target, CONFIGURED_NORMALIZED_VALUE, StringComparison.Ordinal); + } + } + + return normalized; + } + + private static string NormalizeSuffixAfterPrefix(string line, string prefix, string replacement) + { + ReadOnlySpan content = line.AsSpan().TrimStart(); + if (!content.StartsWith(prefix, StringComparison.Ordinal)) + { + return line; + } + + int prefixIndex = line.Length - content.Length; + return string.Concat(line.AsSpan(0, prefixIndex + prefix.Length), replacement); } /// @@ -252,50 +499,82 @@ 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 warnings = new List(); - if (normalizedStrings == null || normalizedStrings.Count == 0) + 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 = ValidateMinimumLength( + effectiveStrings, + nameof(ConfigSettings.ILNormalizeContainingStrings), + "normalize legitimate IL content"); + + 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)) { - return warnings; + warnings.Add($"{nameof(ConfigSettings.ILNormalizeContainingStrings)}: \"{duplicate}\" is configured more than once. Duplicate entries are redundant."); } - foreach (var s in normalizedStrings) + for (int i = 0; i < effectiveStrings.Count; i++) { - if (s.Length < IL_FILTER_STRING_MIN_LENGTH) + for (int j = i + 1; j < effectiveStrings.Count; j++) { - warnings.Add($"ILIgnoreLineContainingStrings: \"{s}\" is very short ({s.Length} chars) and may inadvertently exclude legitimate IL lines. Consider using a more specific pattern."); + string first = effectiveStrings[i]; + string second = effectiveStrings[j]; + if (first.Contains(second, StringComparison.Ordinal) || second.Contains(first, StringComparison.Ordinal)) + { + warnings.Add($"{nameof(ConfigSettings.ILNormalizeContainingStrings)}: \"{first}\" and \"{second}\" overlap by containment. Configured normalization uses sequential ordinal replacement in listed order, so the result may depend on their order. Use non-overlapping values or verify the reported application order."); + } } } 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 List ValidateMinimumLength( + IReadOnlyCollection normalizedStrings, + string settingName, + string riskDescription) { - if (config?.ILIgnoreLineContainingStrings == null) + var warnings = new List(); + if (normalizedStrings == null || normalizedStrings.Count == 0) { - return new List(); + return warnings; } - return config.ILIgnoreLineContainingStrings - .Where(value => !string.IsNullOrWhiteSpace(value)) - .Select(value => value.Trim()) - .Distinct(StringComparer.Ordinal) - .ToList(); + foreach (var s in normalizedStrings) + { + if (s.Length < IL_FILTER_STRING_MIN_LENGTH) + { + warnings.Add($"{settingName}: \"{s}\" is very short ({s.Length} chars) and may inadvertently {riskDescription}. Consider using a more specific pattern."); + } + } + + return warnings; } /// diff --git a/Services/ILOutputService.cs b/Services/ILOutputService.cs index 9a71333f..3603e396 100644 --- a/Services/ILOutputService.cs +++ b/Services/ILOutputService.cs @@ -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); @@ -190,11 +192,11 @@ public async Task PrecomputeAsync(IEnumerable filesAbsolutePaths, int ma // 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); if (!areILsEqual) { - var filtered1 = FilterIlLines(il1Lines, shouldIgnore, ilIgnoreContainingStrings, ignoreMVID); - var filtered2 = FilterIlLines(il2Lines, shouldIgnore, ilIgnoreContainingStrings, ignoreMVID); + var filtered1 = FilterIlLines(il1Lines, shouldIgnore, ilIgnoreContainingStrings, ilNormalizeContainingStrings); + var filtered2 = FilterIlLines(il2Lines, shouldIgnore, ilIgnoreContainingStrings, ilNormalizeContainingStrings); LogEmptyFilteredLineSetIfNeeded( fileRelativePath, file1AbsolutePath, @@ -208,7 +210,6 @@ public async Task PrecomputeAsync(IEnumerable filesAbsolutePaths, int ma shouldOutputIlText, shouldIgnore, ilIgnoreContainingStrings.Count, - ignoreMVID, attemptNumber); areILsEqual = BlockAwareSequenceEqual(filtered1, filtered2); @@ -218,8 +219,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); + var filteredIl2Lines = FilterIlLines(il2Lines, shouldIgnore, ilIgnoreContainingStrings, ilNormalizeContainingStrings); LogEmptyFilteredLineSetIfNeeded( fileRelativePath, file1AbsolutePath, @@ -227,27 +228,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 +277,6 @@ private void LogEmptyFilteredLineSetIfNeeded( bool shouldOutputIlText, bool shouldIgnoreContainingStrings, int ignoreStringCount, - bool shouldIgnoreMvid, int attemptNumber) { bool filteredLineSetIsEmpty = !rawLineSetIsEmpty && (filteredOldLineCount == 0 || filteredNewLineCount == 0); @@ -300,7 +299,6 @@ private void LogEmptyFilteredLineSetIfNeeded( shouldOutputIlText, shouldIgnoreContainingStrings, ignoreStringCount, - shouldIgnoreMvid, attemptNumber, EMPTY_IL_LINE_SET_DISASSEMBLY_ATTEMPT_LIMIT, willRetry: false); @@ -320,7 +318,6 @@ private void LogIlLineSetDiagnostics( bool shouldOutputIlText, bool shouldIgnoreContainingStrings, int ignoreStringCount, - bool shouldIgnoreMvid, int attemptNumber, int maxAttemptCount, bool willRetry) @@ -328,9 +325,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/SectionWriters/HeaderSectionWriter.cs b/Services/SectionWriters/HeaderSectionWriter.cs index 13f16cb1..d7a219f2 100644 --- a/Services/SectionWriters/HeaderSectionWriter.cs +++ b/Services/SectionWriters/HeaderSectionWriter.cs @@ -48,42 +48,105 @@ 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** — All listed rules are applied to every {Constants.LABEL_IL} text, regardless of which disassembler produced it."); + writer.WriteLine(); + writer.WriteLine("When a line matches, the listed prefix is preserved and only the build-variant value that follows it is replaced with the corresponding stable marker."); + writer.WriteLine(); + writer.WriteLine("For ilspycmd's multiline TypeLibraryTimeStampAttribute form, the header prefix is preserved and the complete blob through its closing `)` is replaced with one stable marker."); + writer.WriteLine(); + writer.WriteLine("**Disassembler (Observed In)** identifies the disassembler whose real output established the pattern; it does not limit where the rule is applied:"); + writer.WriteLine(); + writer.WriteLine("The rules are listed in application order."); + writer.WriteLine(); + writer.WriteLine("| Line Prefix Pattern | Replacement | Disassembler (Observed In) |"); + 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** — When diffing {Constants.LABEL_IL}, every occurrence of each configured substring is replaced with `{ILOutputService.CONFIGURED_NORMALIZED_VALUE}`; all remaining text on the line stays unchanged and comparable:"); + writer.WriteLine(); + writer.WriteLine("The substrings are listed in application order."); + 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 |"); + 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 |"); + 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) + { + var firstNonWhitespace = 0; + while (firstNonWhitespace < value.Length && char.IsWhiteSpace(value[firstNonWhitespace])) { - writer.WriteLine($"> {NOTE_MVID_SKIP}"); - writer.WriteLine(); + firstNonWhitespace++; + } + + var lastNonWhitespace = value.Length - 1; + while (lastNonWhitespace >= firstNonWhitespace && char.IsWhiteSpace(value[lastNonWhitespace])) + { + lastNonWhitespace--; + } + + var encoded = new System.Text.StringBuilder(value.Length); + for (var i = 0; i < value.Length; i++) + { + if ((i < firstNonWhitespace || i > lastNonWhitespace) && char.IsWhiteSpace(value[i])) + { + encoded.Append("&#").Append((int)value[i]).Append(';'); + } + else + { + encoded.Append(System.Net.WebUtility.HtmlEncode(value[i].ToString())); + } } + return encoded.ToString() + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace('\r', '\n') + .Replace("|", "|", StringComparison.Ordinal) + .Replace("\n", "
", StringComparison.Ordinal); } } } diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 1e802414..e05d1eb1 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 ``` @@ -227,7 +229,7 @@ Normal diff runs accept exactly two or three positional arguments. A fourth posi | `--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-il-ignore-profile ` | Apply a maintainer-managed IL ignore profile and force [`ShouldIgnoreILLinesContainingConfiguredStrings`](#config-en-shouldignoreillinescontainingconfiguredstrings) to `true`. Profile strings are merged into [`ILIgnoreLineContainingStrings`](#config-en-ilignorelinecontainingstrings). Current built-in profile: `buildserver-winforms`. | | `--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,10 @@ 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 `` before comparison. 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 **Disassembler (Observed In)** value records where that syntax was verified, not a condition that limits rule application. +- 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. +- Effective `ILNormalizeContainingStrings` values are applied in their `config.json` array order and the report table preserves that same application order. Empty values are omitted and exact duplicates appear only once in the effective table. Configured normalization uses sequential ordinal `string.Replace`; when one distinct value contains another, the result can depend on array order. Exact duplicates and containment relationships produce warnings in the console and report. - 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 +566,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 +647,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. + + + ILNormalizeContainingStrings + [] + Strings whose matching portions are replaced with <nildiff:normalized:configured-value> before IL comparison. 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. Values are applied in array order and shown in that same order in reports. Sequential ordinal replacement can be order-dependent when one distinct value contains another; exact duplicates are redundant and appear once in the effective application table. Duplicate or containment relationships, and strings shorter than 4 characters, trigger safety warnings in both console output and reports. ShouldOutputFileTimestamps @@ -840,6 +852,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,7 +866,7 @@ 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). @@ -1044,7 +1057,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 +1092,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 @@ -1384,7 +1399,10 @@ 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) の一致部分を `` へ置換してから比較します。置換は命令の文脈を解釈せず、すべての IL 行へ適用します。プログラムの文字列リテラルを評価スタックへ読み込む命令である `ldstr` も例外ではなく、そのリテラル内では設定文字列に一致した部分だけを置換し、行の残りを比較対象に保持します。MVID、RVA、code-size コメント、WinForms の `TypeLibraryTimeStampAttribute` 値は、全モードで規則別プレースホルダーへ正規化します。ilspycmd の複数行 `TypeLibraryTimeStampAttribute` 形式では、attribute header の接頭辞を保持し、閉じ `)` までのbyte blob全体を1個の安定したマーカーへ置換します。組み込みパターンは使用した逆アセンブラに関係なく、すべての IL text に対して全件評価されます。レポートの **Disassembler (Observed In)** はその構文を確認した由来を示すだけで、規則の適用条件ではありません。 +- 組み込み正規化規則はレポートに表示された順で適用します。現行規則の行接頭辞パターンは互いに重ならないため、適用順は結果へ影響しません。特に `// Code size ` と `// Code size: ` は、空白とコロンの位置が異なる別パターンです。 +- 実効 `ILNormalizeContainingStrings` は `config.json` の配列順で適用し、レポートの表にも同じ適用順で表示します。空の値は省略し、完全重複値は実効表へ1回だけ表示します。設定正規化はordinalな `string.Replace`を逐次実行するため、異なる設定値の一方が他方を含む場合は配列順によって結果が変わり得ます。完全重複または包含関係がある場合は、コンソールとレポートに警告します。 - フィルタ前の raw 逆アセンブル結果が old/new の少なくとも片側で空になった場合、`nildiff` は同じペアを最大 5 回まで再試行します。それでも少なくとも片側が空なら、空の `*_IL.txt` を書かずに error で比較を失敗させます。 - raw 逆アセンブルは成功し、その後のフィルタで少なくとも片側の IL 行がすべて除外された場合は、設定された無視ルールの正当な結果でありえるため、空のフィルタ後行セットをログに残しますが再試行はしません。 - IL 比較そのものに失敗した場合は、弱い比較へ黙って落とさず、その実行全体を停止します。 @@ -1454,7 +1472,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 +1553,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 による部分一致正規化を有効化する。 + + + ILNormalizeContainingStrings + [] + IL 比較前に一致部分を <nildiff:normalized:configured-value> へ置換する文字列一覧。置換は ldstr が読み込む文字列リテラルを含む、すべての IL 命令文脈へ適用する。空白だけの値は無視するが、空でない各値の前後空白は正確な部分一致条件の一部として保持する。ILIgnoreLineContainingStrings と異なり、行の周辺部分は比較対象として残る。配列順で逐次置換し、レポートにも同じ適用順で表示する。異なる値の一方が他方を含む場合はordinalな逐次置換の結果が配列順に依存し得る。完全重複値は実効適用表に1回だけ表示する。4文字未満、完全重複、包含関係のある設定値にはコンソールとレポートの両方で安全性警告を表示する。 ShouldOutputFileTimestamps @@ -1733,6 +1758,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,7 +1772,7 @@ 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 --git a/doc/DEVELOPER_GUIDE.md b/doc/DEVELOPER_GUIDE.md index 5d52aada..9efca8e5 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, IL filter string 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 `DisassemblePairWithSameDisassemblerAsync(...)`, 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`, it also replaces each effective [`ILNormalizeContainingStrings`](../Models/ConfigSettings.cs) match with `` 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. - 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. @@ -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) に委譲され、内部で `DisassemblePairWithSameDisassemblerAsync(...)`、ビルド依存 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` の場合は、実効 [`ILNormalizeContainingStrings`](../Models/ConfigSettings.cs) の一致部分も `` へ置換し、行の残りは比較対象として保持します。空でない正規化値の前後空白はordinalな正確部分一致の一部として保持し、空白だけの値は除外します。 - `.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 使用量をログへ残しつつ、実効ワーカー数を下げるか逐次比較へフォールバックします。 @@ -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..adb61fe8 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 | @@ -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 ファイル並列分類の決定論性、レイテンシシミュレーション付きストレステスト | 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 /
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); diff --git a/Runner/creator_il_ignore_profiles.json b/Runner/creator_il_ignore_profiles.json index c9ff2e17..9b08b7a4 100644 --- a/Runner/creator_il_ignore_profiles.json +++ b/Runner/creator_il_ignore_profiles.json @@ -1,7 +1,7 @@ { "profiles": [ { - "name": "buildserver-winforms", + "name": "creator-default", "strings": [ "buildserver1_", "buildserver2_", diff --git a/USER_GUIDE.md b/USER_GUIDE.md index e05d1eb1..a7d12848 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -228,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`. Profile strings are merged into [`ILIgnoreLineContainingStrings`](#config-en-ilignorelinecontainingstrings). Current built-in profile: `buildserver-winforms`. | +| `--creator` | Apply the default maintainer IL ignore profile (`creator-default`). 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`. Profile strings are merged into [`ILIgnoreLineContainingStrings`](#config-en-ilignorelinecontainingstrings). Current built-in profile: `creator-default`. | | `--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. | @@ -1134,8 +1134,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`)を適用します。想定する常用形は `nildiff [label] --creator` です。 | +| `--creator-il-ignore-profile ` | メンテナー管理の IL 無視プロファイルを適用し、[`ShouldIgnoreILLinesContainingConfiguredStrings`](#config-ja-shouldignoreillinescontainingconfiguredstrings) を `true` に強制します。プロファイル文字列は [`ILIgnoreLineContainingStrings`](#config-ja-ilignorelinecontainingstrings) へマージされます。組み込みプロファイルは現在 `creator-default` です。 | | `--wizard` | 対話モード: 旧フォルダ、新フォルダ、任意のレポートラベルを対話入力で指定します。レポートラベル入力前に、現在の Reports ルート配下にある既存レポートフォルダ名を一覧表示するため、重複回避や既存ラベルの一部再利用がしやすくなります。レポートラベル入力は Enter だけで空欄確定でき、その場合は高粒度のタイムスタンプラベルを自動生成します。ドラッグ&ドロップ対応 — 囲みクォート、`file://` URI プレフィックス、バックスラッシュエスケープされたスペース、パーセントエンコード文字を自動除去します。 | | `--dry-run` | 比較を実行せずファイルを列挙し統計情報を表示します。 | | `--fail-on-diff` | CI ゲートを opt-in します。レポート、監査ログ、その他の有効な成果物をすべて生成した後、最終的な Added/Removed/Modified が残る場合に終了コード `5` を返します。`IgnoredExtensions`、IL ノイズ抑制、その他の比較フィルタで除外された差分はコード `5` の対象外です。このフラグがなければ、差分をレポートした正常な比較も従来どおり `0` を返します。 | diff --git a/doc/config.sample.jsonc b/doc/config.sample.jsonc index 1c362c63..481ccbec 100644 --- a/doc/config.sample.jsonc +++ b/doc/config.sample.jsonc @@ -106,8 +106,8 @@ // Strings shorter than 4 characters trigger a safety warning in both console output and reports. // 無視する文字列 — ShouldIgnoreILLinesContainingConfiguredStrings が true の場合のみ使用 // 4 文字未満の文字列はコンソールとレポートに安全性警告を出力する。 - // Maintainer CLI profile: --creator (default) or --creator-il-ignore-profile buildserver-winforms - // メンテナー用 CLI プロファイル: --creator(既定)または --creator-il-ignore-profile buildserver-winforms + // Maintainer CLI profile: --creator (default) or --creator-il-ignore-profile creator-default + // メンテナー用 CLI プロファイル: --creator(既定)または --creator-il-ignore-profile creator-default "ILIgnoreLineContainingStrings": [], // Normalize configured matching portions during IL comparison (default: false). From a34d00b322ca9baedc4370355ed680c1110d0258 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 15 Aug 2026 01:09:34 +0900 Subject: [PATCH 4/8] Migrate creator IL defaults to normalization --- CHANGELOG.md | 12 +-- .../ProgramRunnerTests.CliOverrides.cs | 18 ++-- .../Runner/CliOverrideApplierTests.cs | 96 +++++++++++++++---- PACKAGE_README.md | 2 +- Runner/CliOverrideApplier.cs | 21 ++-- Runner/CreatorPrivilegeIlIgnoreProfiles.cs | 8 +- Runner/ProgramRunner.HelpText.cs | 4 +- Services/ILOutputService.Comparison.cs | 2 +- USER_GUIDE.md | 24 ++--- doc/config.sample.jsonc | 6 +- doc/config.schema.json | 4 +- doc/samples/diff_report.html | 2 +- doc/samples/diff_report.md | 2 +- 13 files changed, 134 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eeaac733..95655867 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,14 +11,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), #### Added -- **Configurable IL substring normalization** — Added `ShouldILNormalizeContainingConfiguredStrings` and `ILNormalizeContainingStrings`. When enabled, every occurrence of each configured substring is replaced with ``, while all surrounding text on the same IL line remains comparable. 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. 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. +- **Configurable IL substring normalization** — Added `ShouldILNormalizeContainingConfiguredStrings` and `ILNormalizeContainingStrings`. When enabled, every occurrence of each configured substring is replaced with ``, while all surrounding text on the same IL line remains comparable. 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`). #### Changed - **Build-variant IL values are normalized instead of discarded** — MVID, method RVA, code-size, and WinForms `AxHost.TypeLibraryTimeStampAttribute` values are now 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 ignore defaults preserve more meaningful differences** — Renamed the built-in ignore profile from `buildserver-winforms` to `creator-default` and removed its RVA, code-size, WinForms `AxHost.TypeLibraryTimeStampAttribute`, and `.publickeytoken = (` entries. The first three are now handled by built-in normalization in every mode. Public key token changes are no longer suppressed, so AssemblyRef token differences 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. Guidance clarifies sequential replacement order, why the current built-in 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. +- **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 replacement order, 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. #### Removed @@ -1736,14 +1736,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), #### 追加 -- **設定可能な IL 部分文字列正規化** — `ShouldILNormalizeContainingConfiguredStrings` と `ILNormalizeContainingStrings` を追加しました。有効時は、設定した各部分文字列のすべての出現箇所を `` へ置換し、同じIL行にある周辺テキストは比較可能な状態で残します。設定正規化と設定行除外はどちらも、`ldstr` が読み込むプログラム文字列リテラルを含む、すべての IL 命令文脈へ適用します。空でない正規化値の前後空白は正確な一致条件の一部として保持し、空白だけの値は無視します。これは、一致した行全体を比較から除外する従来の `ILIgnoreLineContainingStrings` とは独立した機能です。4文字未満の正規化値は、短い無視値と同じコンソール/レポート安全性警告の対象とし、正規のIL内容まで正規化するリスクが分かる文言で表示します。 +- **設定可能な IL 部分文字列正規化** — `ShouldILNormalizeContainingConfiguredStrings` と `ILNormalizeContainingStrings` を追加しました。有効時は、設定した各部分文字列のすべての出現箇所を `` へ置換し、同じ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`)が必要です。 #### 変更 - **ビルド依存 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` へ改名し、RVA、code-size、WinForms `AxHost.TypeLibraryTimeStampAttribute`、`.publickeytoken = (` の4エントリを除去しました。最初の3つは全モードの組み込み正規化で処理します。public key token変更は抑制しなくなったため、AssemblyRefのtoken差分はcreatorモードでも残ります。 -- **正規化レポートとツール案内を明確化** — Markdown/HTMLレポートには、組み込み正規化規則、設定正規化部分文字列、設定無視部分文字列の順で表示します。組み込み表と設定正規化表には、行の並びが適用順であることを明記します。表では `Line Prefix Pattern`、`Substring to Normalize`、`Substring to Ignore` を区別し、設定値へ装飾的なダブルクオートを付けません。有効だが実効値が0件の場合は空の表を出さず、設定値がないことだけを明示します。組み込み表には各パターンの置換マーカーと確認元逆アセンブラも表示します。重複または包含関係にある正規化値はコンソール/レポート警告の対象にしました。逐次置換の適用順、現行の組み込み規則で順序が結果へ影響しない理由、`nildiff` 本体が `net8.0` をtargetとすること、`ilspycmd 9.1.0.7988` を.NET 8/9/10をtarget frameworkとするAssembly調査の固定fallback baselineとすることを案内に明記しました。英日両方の設定文書、schema、guide、レポートサンプルを新しい挙動へ揃えました。 +- **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プロファイル値を設定値より前へ追加した結果生じた関係も含めてコンソール/レポート警告の対象にしました。逐次置換の適用順、現行の組み込み規則とcreator規則で順序が結果へ影響しない理由、`nildiff` 本体が `net8.0` をtargetとすること、`ilspycmd 9.1.0.7988` を.NET 8/9/10をtarget frameworkとするAssembly調査の固定fallback baselineとすることを案内に明記しました。英日両方の設定文書、schema、guide、レポートサンプルを新しい挙動へ揃えました。 #### 削除 diff --git a/FolderDiffIL4DotNet.Tests/ProgramRunnerTests.CliOverrides.cs b/FolderDiffIL4DotNet.Tests/ProgramRunnerTests.CliOverrides.cs index d2632b49..4b91398d 100644 --- a/FolderDiffIL4DotNet.Tests/ProgramRunnerTests.CliOverrides.cs +++ b/FolderDiffIL4DotNet.Tests/ProgramRunnerTests.CliOverrides.cs @@ -660,7 +660,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 +671,8 @@ public async Task RunAsync_PrintConfigFlag_WithCreatorIlIgnoreProfile_OutputsMer const string configJson = """ { "ShouldIgnoreILLinesContainingConfiguredStrings": false, - "ILIgnoreLineContainingStrings": ["existing-filter"] + "ILIgnoreLineContainingStrings": ["existing-filter"], + "ILNormalizeContainingStrings": ["existing-normalization"] } """; @@ -683,12 +684,14 @@ await WithConfigFileAsync(configJson, 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("existing-filter", output, StringComparison.Ordinal); + Assert.Contains("existing-normalization", output, StringComparison.Ordinal); Assert.Contains("buildserver1_", output, StringComparison.Ordinal); - Assert.DoesNotContain("// Method begins at Relative Virtual Address (RVA) 0x", output, StringComparison.Ordinal); - Assert.DoesNotContain(".publickeytoken = ( ", output, StringComparison.Ordinal); - Assert.DoesNotContain("TypeLibraryTimeStampAttribute", 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); @@ -717,7 +720,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/Runner/CliOverrideApplierTests.cs b/FolderDiffIL4DotNet.Tests/Runner/CliOverrideApplierTests.cs index 166a53d2..9e8719b0 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,29 +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 = "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.DoesNotContain("// Method begins at Relative Virtual Address (RVA) 0x", 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); - Assert.DoesNotContain(".custom instance void class [System.Windows.Forms]System.Windows.Forms.AxHost/TypeLibraryTimeStampAttribute::.ctor(string) = ( ", builder.ILIgnoreLineContainingStrings); - Assert.DoesNotContain("// 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); + } + + [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] @@ -132,11 +178,27 @@ 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] diff --git a/PACKAGE_README.md b/PACKAGE_README.md index 776bf474..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 `creator-default` `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/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 8a380549..1335266c 100644 --- a/Runner/CreatorPrivilegeIlIgnoreProfiles.cs +++ b/Runner/CreatorPrivilegeIlIgnoreProfiles.cs @@ -7,8 +7,8 @@ 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 { @@ -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.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/Services/ILOutputService.Comparison.cs b/Services/ILOutputService.Comparison.cs index 5cb47917..5f78731c 100644 --- a/Services/ILOutputService.Comparison.cs +++ b/Services/ILOutputService.Comparison.cs @@ -536,7 +536,7 @@ internal static List ValidateILNormalizeContainingStrings(IReadOnlyList< .Where(group => group.Count() > 1) .Select(group => group.Key)) { - warnings.Add($"{nameof(ConfigSettings.ILNormalizeContainingStrings)}: \"{duplicate}\" is configured more than once. Duplicate entries are redundant."); + warnings.Add($"{nameof(ConfigSettings.ILNormalizeContainingStrings)}: \"{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++) diff --git a/USER_GUIDE.md b/USER_GUIDE.md index a7d12848..4e62baa5 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -228,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 (`creator-default`). 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`. Profile strings are merged into [`ILIgnoreLineContainingStrings`](#config-en-ilignorelinecontainingstrings). Current built-in profile: `creator-default`. | +| `--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. Current built-in profile: `creator-default`. | | `--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. | @@ -495,8 +495,8 @@ 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 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 `` before comparison. 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 **Disassembler (Observed In)** value records where that syntax was verified, not a condition that limits rule application. -- 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. -- Effective `ILNormalizeContainingStrings` values are applied in their `config.json` array order and the report table preserves that same application order. Empty values are omitted and exact duplicates appear only once in the effective table. Configured normalization uses sequential ordinal `string.Replace`; when one distinct value contains another, the result can depend on array order. Exact duplicates and containment relationships produce warnings in the console and report. +- 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 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 `string.Replace`; when one distinct value contains another, the result can depend on application order. 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. - 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. @@ -655,12 +655,12 @@ Override only the settings you want to change. For example: ShouldILNormalizeContainingConfiguredStrings false - Enables configured substring normalization through ILNormalizeContainingStrings. + Enables configured substring normalization through ILNormalizeContainingStrings. Creator profiles set this to true. ILNormalizeContainingStrings [] - Strings whose matching portions are replaced with <nildiff:normalized:configured-value> before IL comparison. 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. Values are applied in array order and shown in that same order in reports. Sequential ordinal replacement can be order-dependent when one distinct value contains another; exact duplicates are redundant and appear once in the effective application table. Duplicate or containment relationships, and strings shorter than 4 characters, trigger safety warnings in both console output and reports. + Strings whose matching portions are replaced with <nildiff:normalized:configured-value> before IL comparison. 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. Sequential ordinal replacement can be order-dependent when one distinct value contains another; exact duplicates are redundant and appear once in the effective application table. 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. ShouldOutputFileTimestamps @@ -1134,8 +1134,8 @@ nildiff [reportLabel] [options] | `--clear-cache` | IL キャッシュファイルを選択的に削除する対話ウィザードを起動します(ツール別、バージョン別、全削除)。read-only 属性付きの `.ilcache` も削除前に属性解除して処理します。 | | `--skip-il` | .NET アセンブリの IL 比較をまるごとスキップします。 | | `--no-timestamp-warnings` | タイムスタンプ逆転警告を抑制します。 | -| `--creator` | 既定のメンテナー用 IL 無視プロファイル(`creator-default`)を適用します。想定する常用形は `nildiff [label] --creator` です。 | -| `--creator-il-ignore-profile ` | メンテナー管理の IL 無視プロファイルを適用し、[`ShouldIgnoreILLinesContainingConfiguredStrings`](#config-ja-shouldignoreillinescontainingconfiguredstrings) を `true` に強制します。プロファイル文字列は [`ILIgnoreLineContainingStrings`](#config-ja-ilignorelinecontainingstrings) へマージされます。組み込みプロファイルは現在 `creator-default` です。 | +| `--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` です。 | | `--wizard` | 対話モード: 旧フォルダ、新フォルダ、任意のレポートラベルを対話入力で指定します。レポートラベル入力前に、現在の Reports ルート配下にある既存レポートフォルダ名を一覧表示するため、重複回避や既存ラベルの一部再利用がしやすくなります。レポートラベル入力は Enter だけで空欄確定でき、その場合は高粒度のタイムスタンプラベルを自動生成します。ドラッグ&ドロップ対応 — 囲みクォート、`file://` URI プレフィックス、バックスラッシュエスケープされたスペース、パーセントエンコード文字を自動除去します。 | | `--dry-run` | 比較を実行せずファイルを列挙し統計情報を表示します。 | | `--fail-on-diff` | CI ゲートを opt-in します。レポート、監査ログ、その他の有効な成果物をすべて生成した後、最終的な Added/Removed/Modified が残る場合に終了コード `5` を返します。`IgnoredExtensions`、IL ノイズ抑制、その他の比較フィルタで除外された差分はコード `5` の対象外です。このフラグがなければ、差分をレポートした正常な比較も従来どおり `0` を返します。 | @@ -1401,8 +1401,8 @@ flowchart TD - `Added` / `Removed` / `Unchanged` / `Modified` は、ファイル名だけでなく相対パスを基準に決まります。 - [`ShouldIgnoreILLinesContainingConfiguredStrings`](#config-ja-shouldignoreillinescontainingconfiguredstrings) が `true` の場合は、設定した文字列を含む行を IL 比較から除外します。一致判定は命令の文脈を解釈せず、すべての IL 行へ適用します。プログラムの文字列リテラルを評価スタックへ読み込む命令である `ldstr` も例外ではなく、そのリテラル内で設定値が一致した場合は `ldstr` 行全体を除外します。 - [`ShouldILNormalizeContainingConfiguredStrings`](#config-ja-shouldilnormalizecontainingconfiguredstrings) が `true` の場合は、[`ILNormalizeContainingStrings`](#config-ja-ilnormalizecontainingstrings) の一致部分を `` へ置換してから比較します。置換は命令の文脈を解釈せず、すべての IL 行へ適用します。プログラムの文字列リテラルを評価スタックへ読み込む命令である `ldstr` も例外ではなく、そのリテラル内では設定文字列に一致した部分だけを置換し、行の残りを比較対象に保持します。MVID、RVA、code-size コメント、WinForms の `TypeLibraryTimeStampAttribute` 値は、全モードで規則別プレースホルダーへ正規化します。ilspycmd の複数行 `TypeLibraryTimeStampAttribute` 形式では、attribute header の接頭辞を保持し、閉じ `)` までのbyte blob全体を1個の安定したマーカーへ置換します。組み込みパターンは使用した逆アセンブラに関係なく、すべての IL text に対して全件評価されます。レポートの **Disassembler (Observed In)** はその構文を確認した由来を示すだけで、規則の適用条件ではありません。 -- 組み込み正規化規則はレポートに表示された順で適用します。現行規則の行接頭辞パターンは互いに重ならないため、適用順は結果へ影響しません。特に `// Code size ` と `// Code size: ` は、空白とコロンの位置が異なる別パターンです。 -- 実効 `ILNormalizeContainingStrings` は `config.json` の配列順で適用し、レポートの表にも同じ適用順で表示します。空の値は省略し、完全重複値は実効表へ1回だけ表示します。設定正規化はordinalな `string.Replace`を逐次実行するため、異なる設定値の一方が他方を含む場合は配列順によって結果が変わり得ます。完全重複または包含関係がある場合は、コンソールとレポートに警告します。 +- 組み込み正規化規則はレポートに表示された順で適用します。現行規則の行接頭辞パターンは互いに重ならないため、適用順は結果へ影響しません。特に `// Code size ` と `// Code size: ` は、空白とコロンの位置が異なる別パターンです。現行の `creator-default` 正規化文字列にも相互の包含関係がないため、その順序は結果へ影響しません。 +- creatorモードでない場合、実効 `ILNormalizeContainingStrings` は `config.json` の配列順で適用します。creatorモードでは、プロファイル値をプロファイル内の順序で先に適用し、その後に設定値を `config.json` の配列順で適用します。レポートの表にも、この結合後の適用順で表示します。空の値は省略し、完全重複値は実効表へ1回だけ表示します。設定正規化はordinalな `string.Replace`を逐次実行するため、異なる設定値の一方が他方を含む場合は適用順によって結果が変わり得ます。完全重複または包含関係がある場合は、creatorプロファイル値を設定値より前へ追加した結果生じた関係も含めて、コンソールとレポートに警告します。 - フィルタ前の raw 逆アセンブル結果が old/new の少なくとも片側で空になった場合、`nildiff` は同じペアを最大 5 回まで再試行します。それでも少なくとも片側が空なら、空の `*_IL.txt` を書かずに error で比較を失敗させます。 - raw 逆アセンブルは成功し、その後のフィルタで少なくとも片側の IL 行がすべて除外された場合は、設定された無視ルールの正当な結果でありえるため、空のフィルタ後行セットをログに残しますが再試行はしません。 - IL 比較そのものに失敗した場合は、弱い比較へ黙って落とさず、その実行全体を停止します。 @@ -1561,12 +1561,12 @@ JSON Schema ファイル([`doc/config.schema.json`](doc/config.schema.json)) ShouldILNormalizeContainingConfiguredStrings false - ILNormalizeContainingStrings による部分一致正規化を有効化する。 + ILNormalizeContainingStrings による部分一致正規化を有効化する。creator プロファイル適用時は true になる。 ILNormalizeContainingStrings [] - IL 比較前に一致部分を <nildiff:normalized:configured-value> へ置換する文字列一覧。置換は ldstr が読み込む文字列リテラルを含む、すべての IL 命令文脈へ適用する。空白だけの値は無視するが、空でない各値の前後空白は正確な部分一致条件の一部として保持する。ILIgnoreLineContainingStrings と異なり、行の周辺部分は比較対象として残る。配列順で逐次置換し、レポートにも同じ適用順で表示する。異なる値の一方が他方を含む場合はordinalな逐次置換の結果が配列順に依存し得る。完全重複値は実効適用表に1回だけ表示する。4文字未満、完全重複、包含関係のある設定値にはコンソールとレポートの両方で安全性警告を表示する。 + IL 比較前に一致部分を <nildiff:normalized:configured-value> へ置換する文字列一覧。置換は ldstr が読み込む文字列リテラルを含む、すべての IL 命令文脈へ適用する。空白だけの値は無視するが、空でない各値の前後空白は正確な部分一致条件の一部として保持する。ILIgnoreLineContainingStrings と異なり、行の周辺部分は比較対象として残る。creatorモードでない場合は配列順で逐次置換する。creatorモードでは、プロファイル値をプロファイル内の順序で先に適用し、その後に設定値を配列順で適用する。レポートにも同じ結合後の適用順で表示する。異なる値の一方が他方を含む場合はordinalな逐次置換の結果が適用順に依存し得る。完全重複値は実効適用表に1回だけ表示する。4文字未満、完全重複、包含関係のある値には、creatorプロファイル値を設定値より前へ追加した結果生じた関係も含めて、コンソールとレポートの両方で安全性警告を表示する。 ShouldOutputFileTimestamps diff --git a/doc/config.sample.jsonc b/doc/config.sample.jsonc index 481ccbec..44725d6a 100644 --- a/doc/config.sample.jsonc +++ b/doc/config.sample.jsonc @@ -106,18 +106,20 @@ // Strings shorter than 4 characters trigger a safety warning in both console output and reports. // 無視する文字列 — ShouldIgnoreILLinesContainingConfiguredStrings が true の場合のみ使用 // 4 文字未満の文字列はコンソールとレポートに安全性警告を出力する。 - // Maintainer CLI profile: --creator (default) or --creator-il-ignore-profile creator-default - // メンテナー用 CLI プロファイル: --creator(既定)または --creator-il-ignore-profile creator-default "ILIgnoreLineContainingStrings": [], // Normalize configured matching portions during IL comparison (default: false). + // The --creator profile forcibly enables this setting. // IL 比較時に設定文字列と一致する部分を正規化する(デフォルト: false)。 + // --creator プロファイルはこの設定を強制的に有効化する。 "ShouldILNormalizeContainingConfiguredStrings": false, // Replace matching portions of IL lines with a stable placeholder during comparison. // 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. // IL 行の一致部分を比較時に安定したプレースホルダーへ置換する。 // 前後空白は正確な一致条件の一部として保持し、空白だけの値は無視する。 + // --creator プロファイルはビルドマシン名と一時パスの値をこのリストより前へ追加する。 "ILNormalizeContainingStrings": [], // --- File Extensions / ファイル拡張子 --- diff --git a/doc/config.schema.json b/doc/config.schema.json index ad7d9053..46055fb5 100644 --- a/doc/config.schema.json +++ b/doc/config.schema.json @@ -127,13 +127,13 @@ "ShouldILNormalizeContainingConfiguredStrings": { "type": "boolean", "default": false, - "description": "Replace IL portions matching ILNormalizeContainingStrings with .\nILNormalizeContainingStrings と一致する IL 部分を へ置換する。" + "description": "Replace IL portions matching ILNormalizeContainingStrings with . Creator profiles set this to true.\nILNormalizeContainingStrings と一致する IL 部分を へ置換する。creator プロファイル適用時は true になる。" }, "ILNormalizeContainingStrings": { "type": "array", "items": { "type": "string" }, "default": [], - "description": "Strings whose matching portions are replaced with during IL comparison; surrounding text remains comparable. Leading and trailing whitespace is preserved as part of the exact match, while whitespace-only values are ignored.\nIL比較時に一致部分を へ置換する文字列。周辺テキストは比較対象として残る。前後空白は正確な一致条件の一部として保持し、空白だけの値は無視する。" + "description": "Strings whose matching portions are replaced with during IL comparison; surrounding text remains comparable. Leading and trailing whitespace is preserved as part of the exact match, while whitespace-only values are ignored. Creator-profile values are applied first, followed by these configured values in array order.\nIL比較時に一致部分を へ置換する文字列。周辺テキストは比較対象として残る。前後空白は正確な一致条件の一部として保持し、空白だけの値は無視する。creatorプロファイル値を先に適用し、その後にこの設定値を配列順で適用する。" }, "EnableILCache": { diff --git a/doc/samples/diff_report.html b/doc/samples/diff_report.html index 203102df..a159f2b1 100644 --- a/doc/samples/diff_report.html +++ b/doc/samples/diff_report.html @@ -967,13 +967,13 @@

Folder Diff Report

Substring to Normalize buildserver1_ - buildserver1_artifact buildserver2_ ¥temp¥develop¥ ¥¥temp¥¥develop¥¥ \temp\develop\ \\temp\\develop\\ /temp/develop/ +buildserver1_artifact
IL Ignore Line Containing Strings
diff --git a/doc/samples/diff_report.md b/doc/samples/diff_report.md index 5a8285a6..ce166dff 100644 --- a/doc/samples/diff_report.md +++ b/doc/samples/diff_report.md @@ -50,13 +50,13 @@ The substrings are listed in application order. | Substring to Normalize | |------------------------| | buildserver1_ | -| buildserver1_artifact | | buildserver2_ | | ¥temp¥develop¥ | | ¥¥temp¥¥develop¥¥ | | \temp\develop\ | | \\temp\\develop\\ | | /temp/develop/ | +| buildserver1_artifact | **ILIgnoreLineContainingStrings** — When diffing IL, lines containing any of the configured strings are ignored: From a8efe3e060d31298bf19936a4084ec2213286a17 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 14 Aug 2026 19:54:55 +0900 Subject: [PATCH 5/8] Fix class-internal IL method reordering --- CHANGELOG.md | 2 + FolderDiffIL4DotNet.Core/IL/ILBlockParser.cs | 147 ++++++++++++++++++ .../Services/ILBlockParserTests.cs | 37 +++++ .../Services/ILOutputServiceTests.cs | 110 +++++++++++++ Services/ILOutput/ILBlockParser.cs | 4 + Services/ILOutputService.Comparison.cs | 34 ++-- doc/DEVELOPER_GUIDE.md | 8 +- doc/TESTING_GUIDE.md | 2 + 8 files changed, 323 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95655867..f1d0df1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), #### Changed +- **Class-internal IL member ordering is compared hierarchically** — Order-independent IL comparison now decomposes classes into member blocks and matches each block by parent class path, member signature, and content hash. Reordering methods within the same class no longer produces a false mismatch, while method body changes, swapping bodies between different methods, and moving methods between classes remain differences. Regression tests cover realistic `dotnet-ildasm` and `ilspycmd` class/method layouts. - **Build-variant IL values are normalized instead of discarded** — MVID, method RVA, code-size, and WinForms `AxHost.TypeLibraryTimeStampAttribute` values are now 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 replacement order, 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. @@ -1741,6 +1742,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), #### 変更 +- **class内ILメンバー順を階層付きで比較** — 順序非依存IL比較では、classをmemberブロックへ分解し、親classパス、memberシグネチャ、内容ハッシュの組み合わせで照合するようにしました。同じclass内でmethod順だけが変わっても誤った差分になりません。一方、method本体の変更、異なるmethod間での本体入れ替え、別classへのmethod移動は引き続き差分になります。実際的な `dotnet-ildasm` と `ilspycmd` のclass/method配置を回帰テストで網羅しています。 - **ビルド依存 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プロファイル値を設定値より前へ追加した結果生じた関係も含めてコンソール/レポート警告の対象にしました。逐次置換の適用順、現行の組み込み規則とcreator規則で順序が結果へ影響しない理由、`nildiff` 本体が `net8.0` をtargetとすること、`ilspycmd 9.1.0.7988` を.NET 8/9/10をtarget frameworkとするAssembly調査の固定fallback baselineとすることを案内に明記しました。英日両方の設定文書、schema、guide、レポートサンプルを新しい挙動へ揃えました。 diff --git a/FolderDiffIL4DotNet.Core/IL/ILBlockParser.cs b/FolderDiffIL4DotNet.Core/IL/ILBlockParser.cs index 989ab71f..83454933 100644 --- a/FolderDiffIL4DotNet.Core/IL/ILBlockParser.cs +++ b/FolderDiffIL4DotNet.Core/IL/ILBlockParser.cs @@ -3,6 +3,31 @@ namespace FolderDiffIL4DotNet.Core.IL { + /// + /// A logical IL block paired with the containing class path used for comparison. + /// 比較に使用する包含class pathを伴う論理ILブロックです。 + /// + public sealed class ILComparableBlock + { + internal ILComparableBlock(string containerPath, IReadOnlyList lines) + { + ContainerPath = containerPath; + Lines = new List(lines).AsReadOnly(); + } + + /// + /// Containing class signature path, or empty for top-level blocks. + /// 包含class signature path。トップレベルブロックでは空です。 + /// + public string ContainerPath { get; } + + /// + /// IL lines belonging to this block. + /// このブロックに属するIL行です。 + /// + public IReadOnlyList Lines { get; } + } + /// /// Parses IL disassembly output into top-level blocks (methods, classes, properties, etc.) /// for order-independent comparison. IL lines that fall outside any block are grouped as @@ -88,6 +113,128 @@ public static List> ParseBlocks(IReadOnlyList lines) return blocks; } + /// + /// Parses IL into comparison blocks while preserving class/member hierarchy. + /// A class is represented by a shell block without its directly nested reorderable members; + /// each member is emitted separately with the containing class signature path. + /// class/member階層を保持した比較用ブロックへILを解析します。 + /// classは直接包含する並び替え可能memberを除いたshell blockとして表し、 + /// 各memberは包含class signature path付きで個別に出力します。 + /// + public static List ParseComparableBlocks(IReadOnlyList lines) + { + var comparableBlocks = new List(); + foreach (var block in ParseBlocks(lines)) + { + string signature = ExtractBlockSignature(block); + if (signature.StartsWith(".class ", StringComparison.Ordinal)) + { + AddClassAndMemberBlocks(block, string.Empty, comparableBlocks); + } + else + { + comparableBlocks.Add(new ILComparableBlock(string.Empty, block)); + } + } + + return comparableBlocks; + } + + private static void AddClassAndMemberBlocks( + IReadOnlyList classLines, + string parentPath, + List result) + { + string classSignature = ExtractBlockSignature(classLines).Trim(); + string classPath = string.IsNullOrEmpty(parentPath) + ? classSignature + : string.Concat(parentPath, "\n", classSignature); + + // Direct members are removed from the class shell so their order does not affect the class hash. + // 直接memberをclass shellから切り離し、その並び順がclass hashへ影響しないようにします。 + var classShell = new List(); + int classBraceDepth = 0; + + for (int i = 0; i < classLines.Count; i++) + { + string line = classLines[i]; + string trimmed = line.TrimStart(); + + // Depth 1 identifies direct members; deeper directives belong to the current member body. + // depth 1だけを直接memberとして扱い、それより深いdirectiveは現在のmember本体に残します。 + if (classBraceDepth == 1 && IsReorderableClassMemberStart(trimmed)) + { + int memberEnd = FindNestedBlockEnd(classLines, i); + var memberLines = new List(memberEnd - i + 1); + for (int memberLineIndex = i; memberLineIndex <= memberEnd; memberLineIndex++) + { + memberLines.Add(classLines[memberLineIndex]); + } + + if (trimmed.StartsWith(".class ", StringComparison.Ordinal)) + { + // Recursion keeps the full owning class path, so moving a nested class remains a difference. + // 再帰時も所有class path全体を保持し、nested classの移動は差分として検出します。 + AddClassAndMemberBlocks(memberLines, classPath, result); + } + else + { + result.Add(new ILComparableBlock(classPath, memberLines)); + } + + i = memberEnd; + continue; + } + + classShell.Add(line); + classBraceDepth += CountBraces(trimmed); + } + + result.Add(new ILComparableBlock(parentPath, classShell)); + } + + private static int FindNestedBlockEnd(IReadOnlyList lines, int startIndex) + { + int braceDepth = 0; + bool sawOpeningBrace = false; + for (int i = startIndex; i < lines.Count; i++) + { + string trimmed = lines[i].TrimStart(); + + // Before the opening brace, allow multiline signatures but stop at the next sibling or class end. + // 開始波括弧までは複数行signatureを許容し、次のsiblingまたはclass末尾で打ち切ります。 + if (i > startIndex && !sawOpeningBrace && + (trimmed.StartsWith("}", StringComparison.Ordinal) || IsReorderableClassMemberStart(trimmed))) + { + return i - 1; + } + + int braceChange = CountBraces(trimmed); + if (braceChange > 0) + { + sawOpeningBrace = true; + } + braceDepth += braceChange; + + // Once the body starts, the matching brace closes the complete member, including nested scopes. + // 本体開始後はbrace depthでnested scopeを含むmember全体の対応終端を確定します。 + if (sawOpeningBrace && braceDepth <= 0) + { + return i; + } + } + + return lines.Count - 1; + } + + private static bool IsReorderableClassMemberStart(string trimmedLine) + { + return trimmedLine.StartsWith(".method ", StringComparison.Ordinal) || + trimmedLine.StartsWith(".class ", StringComparison.Ordinal) || + trimmedLine.StartsWith(".property ", StringComparison.Ordinal) || + trimmedLine.StartsWith(".event ", StringComparison.Ordinal); + } + /// /// Extracts the signature (first directive line) from a parsed block. /// Returns an empty string for preamble or inter-block lines that have no directive. diff --git a/FolderDiffIL4DotNet.Tests/Services/ILBlockParserTests.cs b/FolderDiffIL4DotNet.Tests/Services/ILBlockParserTests.cs index 477c7787..7d6aebab 100644 --- a/FolderDiffIL4DotNet.Tests/Services/ILBlockParserTests.cs +++ b/FolderDiffIL4DotNet.Tests/Services/ILBlockParserTests.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using FolderDiffIL4DotNet.Services.ILOutput; using Xunit; using CoreParser = FolderDiffIL4DotNet.Core.IL.ILBlockParser; @@ -132,6 +133,42 @@ public void ParseBlocks_ClassBlock_ParsedCorrectly() Assert.Equal(8, result[0].Count); } + [Fact] + [Trait("Category", "Unit")] + public void ParseComparableBlocks_ClassMembers_PreserveContainerPath() + { + var lines = new List + { + ".class public auto ansi MyClass", + "{", + " .field private int32 _value", + " .method public void Foo() cil managed", + " {", + " ret", + " }", + " .method public void Bar() cil managed", + " {", + " ret", + " }", + "}" + }; + + var result = CoreParser.ParseComparableBlocks(lines); + + Assert.Equal(3, result.Count); + Assert.Contains(result, block => + block.ContainerPath.Length == 0 && + CoreParser.ExtractBlockSignature(block.Lines) == ".class public auto ansi MyClass" && + block.Lines.Contains(" .field private int32 _value") && + !block.Lines.Contains(" .method public void Foo() cil managed")); + Assert.Contains(result, block => + block.ContainerPath == ".class public auto ansi MyClass" && + CoreParser.ExtractBlockSignature(block.Lines) == ".method public void Foo() cil managed"); + Assert.Contains(result, block => + block.ContainerPath == ".class public auto ansi MyClass" && + CoreParser.ExtractBlockSignature(block.Lines) == ".method public void Bar() cil managed"); + } + // --- Brace counting resilience tests / 波括弧カウント耐性テスト --- [Fact] diff --git a/FolderDiffIL4DotNet.Tests/Services/ILOutputServiceTests.cs b/FolderDiffIL4DotNet.Tests/Services/ILOutputServiceTests.cs index f7861211..2444c883 100644 --- a/FolderDiffIL4DotNet.Tests/Services/ILOutputServiceTests.cs +++ b/FolderDiffIL4DotNet.Tests/Services/ILOutputServiceTests.cs @@ -598,6 +598,69 @@ public void BlockAwareSequenceEqual_DuplicateMethods_Reordered_ReturnsTrue() Assert.True(ILOutputService.BlockAwareSequenceEqual(lines1, lines2)); } + [Fact] + [Trait("Category", "Unit")] + public void BlockAwareSequenceEqual_MethodsReorderedWithinClass_ReturnsTrue() + { + var lines1 = BuildClassIl("MyClass", ("Foo", "ldc.i4.0"), ("Bar", "ldc.i4.1")); + var lines2 = BuildClassIl("MyClass", ("Bar", "ldc.i4.1"), ("Foo", "ldc.i4.0")); + + Assert.True(ILOutputService.BlockAwareSequenceEqual(lines1, lines2)); + } + + [Fact] + [Trait("Category", "Unit")] + public void BlockAwareSequenceEqual_IlSpyMultilineMethodsReorderedWithinClass_ReturnsTrue() + { + var lines1 = BuildIlSpyClassIl(("Foo", "ldc.i4.0"), ("Bar", "ldc.i4.1")); + var lines2 = BuildIlSpyClassIl(("Bar", "ldc.i4.1"), ("Foo", "ldc.i4.0")); + + Assert.True(ILOutputService.BlockAwareSequenceEqual(lines1, lines2)); + } + + [Fact] + [Trait("Category", "Unit")] + public void BlockAwareSequenceEqual_ClassSignatureWhitespaceDiffers_ReturnsTrue() + { + var lines1 = BuildClassIl("MyClass", ("Foo", "ldc.i4.0")); + var lines2 = BuildClassIl("MyClass", ("Foo", "ldc.i4.0")); + lines2[0] = $" {lines2[0]} "; + + Assert.True(ILOutputService.BlockAwareSequenceEqual(lines1, lines2)); + } + + [Fact] + [Trait("Category", "Unit")] + public void BlockAwareSequenceEqual_MethodBodyChangedWithinClass_ReturnsFalse() + { + var lines1 = BuildClassIl("MyClass", ("Foo", "ldc.i4.0"), ("Bar", "ldc.i4.1")); + var lines2 = BuildClassIl("MyClass", ("Bar", "ldc.i4.1"), ("Foo", "ldc.i4.2")); + + Assert.False(ILOutputService.BlockAwareSequenceEqual(lines1, lines2)); + } + + [Fact] + [Trait("Category", "Unit")] + public void BlockAwareSequenceEqual_MethodBodiesSwappedWithinClass_ReturnsFalse() + { + var lines1 = BuildClassIl("MyClass", ("Foo", "ldc.i4.0"), ("Bar", "ldc.i4.1")); + var lines2 = BuildClassIl("MyClass", ("Bar", "ldc.i4.0"), ("Foo", "ldc.i4.1")); + + Assert.False(ILOutputService.BlockAwareSequenceEqual(lines1, lines2)); + } + + [Fact] + [Trait("Category", "Unit")] + public void BlockAwareSequenceEqual_MethodBodiesMovedBetweenClasses_ReturnsFalse() + { + var lines1 = BuildClassIl("ClassA", ("Foo", "ldc.i4.0")); + lines1.AddRange(BuildClassIl("ClassB", ("Foo", "ldc.i4.1"))); + var lines2 = BuildClassIl("ClassA", ("Foo", "ldc.i4.1")); + lines2.AddRange(BuildClassIl("ClassB", ("Foo", "ldc.i4.0"))); + + Assert.False(ILOutputService.BlockAwareSequenceEqual(lines1, lines2)); + } + [Fact] [Trait("Category", "Unit")] public void BlockAwareSequenceEqual_ContentSwappedBetweenMethods_ReturnsFalse() @@ -957,5 +1020,52 @@ private static ILOutputService CreateILOutputService(ConfigSettings config, stri var dotNetDisassembleService = new DotNetDisassembleService(config, ilCache: null, resultLists, logger, new DotNetDisassemblerCache(logger)); return new ILOutputService(config, executionContext, ilTextOutputService, dotNetDisassembleService, ilCache: null, logger); } + + private static List BuildClassIl( + string className, + params (string MethodName, string BodyInstruction)[] methods) + { + var lines = new List + { + $".class public auto ansi {className}", + " extends [System.Runtime]System.Object", + "{" + }; + foreach (var method in methods) + { + lines.Add($" .method public void {method.MethodName}() cil managed"); + lines.Add(" {"); + lines.Add($" {method.BodyInstruction}"); + lines.Add(" ret"); + lines.Add($" }} // End of method System.Void {className}::{method.MethodName}()"); + } + lines.Add("}"); + return lines; + } + + private static List BuildIlSpyClassIl( + params (string MethodName, string BodyInstruction)[] methods) + { + var lines = new List + { + ".class public auto ansi MyClass", + " extends [System.Runtime]System.Object", + "{", + " // Methods" + }; + foreach (var method in methods) + { + lines.Add(" .method public hidebysig"); + lines.Add($" instance void {method.MethodName} ("); + lines.Add(" int32 'value'"); + lines.Add(" ) cil managed"); + lines.Add(" {"); + lines.Add($" {method.BodyInstruction}"); + lines.Add(" ret"); + lines.Add($" }} // end of method MyClass::{method.MethodName}"); + } + lines.Add("}"); + return lines; + } } } diff --git a/Services/ILOutput/ILBlockParser.cs b/Services/ILOutput/ILBlockParser.cs index a78417fb..61251ba4 100644 --- a/Services/ILOutput/ILBlockParser.cs +++ b/Services/ILOutput/ILBlockParser.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using FolderDiffIL4DotNet.Core.IL; using CoreILBlockParser = FolderDiffIL4DotNet.Core.IL.ILBlockParser; namespace FolderDiffIL4DotNet.Services.ILOutput @@ -14,6 +15,9 @@ internal static class ILBlockParser internal static List> ParseBlocks(IReadOnlyList lines) => CoreILBlockParser.ParseBlocks(lines); + internal static List ParseComparableBlocks(IReadOnlyList lines) + => CoreILBlockParser.ParseComparableBlocks(lines); + internal static string ExtractBlockSignature(IReadOnlyList blockLines) => CoreILBlockParser.ExtractBlockSignature(blockLines); } diff --git a/Services/ILOutputService.Comparison.cs b/Services/ILOutputService.Comparison.cs index 5f78731c..3d03d5cc 100644 --- a/Services/ILOutputService.Comparison.cs +++ b/Services/ILOutputService.Comparison.cs @@ -414,20 +414,19 @@ private static string NormalizeSuffixAfterPrefix(string line, string prefix, str /// /// Compares two filtered IL line lists using signature-aware, block-based (order-independent) comparison. - /// Parses IL into logical blocks (methods, classes, etc.) via , - /// extracts each block's signature (directive line) and content hash, then compares as multisets - /// of (signature, hash) pairs. This handles compiler-induced reordering while correctly detecting - /// content changes even when blocks with different signatures have identical bodies. + /// Parses IL into hierarchy-aware blocks via , then compares multisets + /// of (container path, signature, hash) tuples. This handles compiler-induced member reordering + /// within a class while still detecting content changes, body swaps, and moves between classes. /// フィルタ済み IL 行リストをシグネチャ対応のブロック単位(順序非依存)で比較します。 /// で IL を論理ブロック(メソッド、クラス等)に分割し、 - /// 各ブロックのシグネチャ(ディレクティブ行)とコンテンツハッシュを抽出してから - /// (シグネチャ, ハッシュ) ペアのマルチセットとして比較します。コンパイラによる並び替えを - /// 許容しつつ、異なるシグネチャのブロック間でのコンテンツ入れ替わりを正しく検知します。 + /// class/member階層を保持したブロックへ解析し、(container path, シグネチャ, ハッシュ) tupleの + /// マルチセットとして比較します。同一class内のmember並び替えを許容しつつ、本体変更、 + /// method間の本体入れ替え、class間移動を正しく検知します。 /// internal static bool BlockAwareSequenceEqual(IReadOnlyList filteredLines1, IReadOnlyList filteredLines2) { - var blocks1 = ILBlockParser.ParseBlocks(filteredLines1); - var blocks2 = ILBlockParser.ParseBlocks(filteredLines2); + var blocks1 = ILBlockParser.ParseComparableBlocks(filteredLines1); + var blocks2 = ILBlockParser.ParseComparableBlocks(filteredLines2); if (blocks1.Count != blocks2.Count) { return false; @@ -452,21 +451,22 @@ internal static bool BlockAwareSequenceEqual(IReadOnlyList filteredLines } /// - /// Builds a multiset ((signature, hash) → count) from a list of IL blocks. + /// Builds a multiset ((container, signature, hash) → count) from a list of IL blocks. /// Each block's signature is extracted via , /// ensuring that blocks are matched by both identity (signature) and content (hash). - /// IL ブロックのリストからマルチセット((シグネチャ, ハッシュ) → 出現回数)を構築します。 + /// IL ブロックのリストからマルチセット((container, シグネチャ, ハッシュ) → 出現回数)を構築します。 /// 各ブロックのシグネチャは で抽出し、 /// ブロックの同一性(シグネチャ)と内容(ハッシュ)の両方で照合します。 /// - private static Dictionary<(string Signature, string Hash), int> BuildBlockHashBag(List> blocks) + private static Dictionary<(string Container, string Signature, string Hash), int> BuildBlockHashBag( + IReadOnlyList blocks) { - var bag = new Dictionary<(string Signature, string Hash), int>(); + var bag = new Dictionary<(string Container, string Signature, string Hash), int>(); foreach (var block in blocks) { - string signature = ILBlockParser.ExtractBlockSignature(block); - string hash = ComputeBlockHash(block); - var key = (signature, hash); + string signature = ILBlockParser.ExtractBlockSignature(block.Lines).Trim(); + string hash = ComputeBlockHash(block.Lines); + var key = (block.ContainerPath, signature, hash); bag.TryGetValue(key, out int count); bag[key] = count + 1; } @@ -478,7 +478,7 @@ internal static bool BlockAwareSequenceEqual(IReadOnlyList filteredLines /// Computes a SHA256 hash of an IL block's content (all lines joined with newline). /// IL ブロックの内容(全行を改行で結合)の SHA256 ハッシュを計算します。 /// - private static string ComputeBlockHash(List blockLines) + private static string ComputeBlockHash(IReadOnlyList blockLines) { using var sha256 = SHA256.Create(); var sb = new StringBuilder(); diff --git a/doc/DEVELOPER_GUIDE.md b/doc/DEVELOPER_GUIDE.md index 9efca8e5..55c3cf32 100644 --- a/doc/DEVELOPER_GUIDE.md +++ b/doc/DEVELOPER_GUIDE.md @@ -457,8 +457,8 @@ 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; `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/ILOutputService.cs`](../Services/ILOutputService.cs) | IL compare flow, line filtering, hierarchy-aware order-independent comparison, optional IL dump writing, IL filter string safety validation | Enforces same disassembler identity; when line order differs, matches blocks by parent class path, member signature, and content hash; `ValidateILIgnoreContainingStrings` and `ValidateILNormalizeContainingStrings` validate configured patterns | +| [`Services/ILOutput/ILBlockParser.cs`](../Services/ILOutput/ILBlockParser.cs) | Parses IL disassembly output into logical blocks and comparison-specific class/member blocks | `ParseComparableBlocks` preserves class hierarchy so member order may differ within one class without hiding body changes or cross-class moves | | [`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` | | [`Services/ChangeImportanceClassifier.cs`](../Services/ChangeImportanceClassifier.cs) | Rule-based importance classifier for `MemberChangeEntry` | Assigns `High` / `Medium` / `Low` [`ChangeImportance`](../Models/ChangeImportance.cs) based on change type, access modifiers, and arrow-notation field changes | @@ -1440,8 +1440,8 @@ sequenceDiagram | [`Services/IFileSystemService.cs`](../Services/IFileSystemService.cs) + [`Services/FileSystemService.cs`](../Services/FileSystemService.cs) | 列挙/出力系ファイルシステム抽象 | フォルダ単位ユニットテスト向け。遅延列挙もここで扱う | | [`Services/FileDiffService.cs`](../Services/FileDiffService.cs) | ファイル単位の判定木 | `SHA256 -> 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 フィルタ文字列安全性検証 | 同一逆アセンブラ制約を保証;行順序が異なる場合はブロック単位マルチセット比較にフォールバック;`ValidateILIgnoreContainingStrings` と `ValidateILNormalizeContainingStrings` が設定パターンを検証 | -| [`Services/ILOutput/ILBlockParser.cs`](../Services/ILOutput/ILBlockParser.cs) | IL 逆アセンブリ出力を論理ブロック(メソッド、クラス、プロパティ)に分割 | `ILOutputService.BlockAwareSequenceEqual` で順序非依存比較に使用 | +| [`Services/ILOutputService.cs`](../Services/ILOutputService.cs) | IL 比較、行除外、階層対応の順序非依存比較、任意 IL 出力、IL フィルタ文字列安全性検証 | 同一逆アセンブラ制約を保証;行順序が異なる場合は親classパス、memberシグネチャ、内容ハッシュでブロックを照合;`ValidateILIgnoreContainingStrings` と `ValidateILNormalizeContainingStrings` が設定パターンを検証 | +| [`Services/ILOutput/ILBlockParser.cs`](../Services/ILOutput/ILBlockParser.cs) | IL 逆アセンブリ出力を論理ブロックと比較専用のclass/memberブロックへ分割 | `ParseComparableBlocks` がclass階層を保持し、本体変更やclass間移動を隠さずに同一class内のmember順変更を許容 | | [`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` の後処理ステップとして呼び出される | | [`Services/ChangeImportanceClassifier.cs`](../Services/ChangeImportanceClassifier.cs) | `MemberChangeEntry` のルールベース重要度分類器 | 変更種別・アクセス修飾子・アロー表記フィールド変更に基づき `High` / `Medium` / `Low` の [`ChangeImportance`](../Models/ChangeImportance.cs) を付与 | diff --git a/doc/TESTING_GUIDE.md b/doc/TESTING_GUIDE.md index adb61fe8..36db2b8e 100644 --- a/doc/TESTING_GUIDE.md +++ b/doc/TESTING_GUIDE.md @@ -65,6 +65,7 @@ Version metadata regression coverage verifies that `SystemInfo` separates the th | Timestamp caching | [`TimestampCacheTests`](../FolderDiffIL4DotNet.Tests/Services/Caching/TimestampCacheTests.cs) | `GetOrAdd` cache hit/miss (second call returns same value), null/empty `ArgumentException`, `Clear` invalidation (modified file timestamp detected after clear), case-insensitive key matching | Testability-related structure: +- IL ordering regressions use realistic class nesting and both `dotnet-ildasm` and `ilspycmd` method-signature layouts. They verify that methods may be reordered only within the same parent class, while body edits, body swaps between signatures, and moves between classes remain mismatches. - [`ProgramTests`](../FolderDiffIL4DotNet.Tests/ProgramTests.cs) exercise the thin `Program.Main` entry point, and [`ProgramRunnerTests`](../FolderDiffIL4DotNet.Tests/ProgramRunnerTests.cs) pin both the phase ordering inside [`ProgramRunner`](../ProgramRunner.cs) and the typed exit-code mapping at the application boundary, which reduces the risk of refactors accidentally loading config before argument validation fails or collapsing distinct failures back into one exit code. - Diff pipeline services now expose interface seams ([`IFileDiffService`](../Services/IFileDiffService.cs), [`IILOutputService`](../Services/IILOutputService.cs), [`IFolderDiffService`](../Services/IFolderDiffService.cs), [`IDotNetDisassembleService`](../Services/IDotNetDisassembleService.cs), [`IILTextOutputService`](../Services/ILOutput/IILTextOutputService.cs)) so tests can replace collaborators directly. - [`FolderDiffExecutionStrategy`](../Services/FolderDiffExecutionStrategy.cs) and [`FolderDiffService`](../Services/FolderDiffService.cs) accept [`IFileSystemService`](../Services/IFileSystemService.cs), which lets unit tests simulate enumeration failures, streaming discovery via [`EnumerateFiles(...)`](https://learn.microsoft.com/en-us/dotnet/api/system.io.directory.enumeratefiles?view=net-8.0), ignored-file capture, output-directory I/O failures, and large file sets without creating real directories. @@ -318,6 +319,7 @@ Workflow/config files: [`.github/workflows/dotnet.yml`](../.github/workflows/dot | タイムスタンプキャッシュ | [`TimestampCacheTests`](../FolderDiffIL4DotNet.Tests/Services/Caching/TimestampCacheTests.cs) | `GetOrAdd` キャッシュヒット/ミス(2 回目の呼び出しは同一値を返す)、null/空の `ArgumentException`、`Clear` 無効化(クリア後にファイルタイムスタンプ変更を検出)、大文字小文字を区別しないキーマッチング | テスタビリティに関する構成: +- IL順序の回帰テストでは、実際的なclass階層と `dotnet-ildasm`/`ilspycmd` 両方のmethodシグネチャ配置を使います。同じ親class内のmethod並び替えだけを許容し、method本体の変更、異なるシグネチャ間での本体入れ替え、class間移動は差分のままであることを検証します。 - [`ProgramTests`](../FolderDiffIL4DotNet.Tests/ProgramTests.cs) は薄い `Program.Main` を対象にし、[`ProgramRunnerTests`](../FolderDiffIL4DotNet.Tests/ProgramRunnerTests.cs) は [`ProgramRunner`](../ProgramRunner.cs) 内のフェーズ順序と型付き終了コード分類を固定します。これにより、引数検証より先に設定読込へ進んでしまう回帰や、異なる失敗理由が再び同じ終了コードへ潰れる回帰を防ぎつつ、静的状態への結合を減らしています。 - 差分パイプラインの主要サービスは [`IFileDiffService`](../Services/IFileDiffService.cs), [`IILOutputService`](../Services/IILOutputService.cs), [`IFolderDiffService`](../Services/IFolderDiffService.cs), [`IDotNetDisassembleService`](../Services/IDotNetDisassembleService.cs), [`IILTextOutputService`](../Services/ILOutput/IILTextOutputService.cs) の差し替えポイントを持ちます。 - [`FolderDiffExecutionStrategy`](../Services/FolderDiffExecutionStrategy.cs) と [`FolderDiffService`](../Services/FolderDiffService.cs) は [`IFileSystemService`](../Services/IFileSystemService.cs) を受け取れるため、ユニットテストでは実ファイルを作らずに列挙失敗・[`EnumerateFiles(...)`](https://learn.microsoft.com/ja-jp/dotnet/api/system.io.directory.enumeratefiles?view=net-8.0) ベースの遅延列挙・無視ファイル記録・出力先 I/O 失敗・大量ファイル入力を再現できます。 From 5dbb1c82627eaeab7cda776ad4515300c22c58fa Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 14 Aug 2026 20:03:18 +0900 Subject: [PATCH 6/8] Document non-identical IL text matches --- doc/TROUBLESHOOTING.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/doc/TROUBLESHOOTING.md b/doc/TROUBLESHOOTING.md index 2fe87ff2..69a395bd 100644 --- a/doc/TROUBLESHOOTING.md +++ b/doc/TROUBLESHOOTING.md @@ -297,6 +297,14 @@ New-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" -Name **Interpretation:** This result is expected and means the two files have identical contents despite different filesystem timestamps. No action is required unless your audit process treats timestamps themselves as significant. If a timestamp embedded inside the file contents actually differs, the bytes normally differ as well and `SHA256Match` should not be reported; confirm whether the timestamp you are examining is filesystem metadata or embedded content. +### Why can `*_IL.txt` files differ when the result is `ILMatch`? + +**Symptom:** `ShouldOutputILText=true` produces old and new `*_IL.txt` files that are not textually identical, but the assembly is reported as `Unchanged` with `ILMatch`. + +**Cause:** The IL text files preserve the normalized and filtered IL line order emitted for each assembly. Comparison first checks those lines in order, then falls back to a hierarchy-aware block comparison when the ordered lines differ. The fallback can accept reordered top-level IL blocks; reordered methods, nested classes, properties, or events within the same containing class; and leading or trailing whitespace differences. It still associates each member with its containing class, signature, and content, so changing a method body, swapping bodies between methods, or moving a method to another class remains an `ILMismatch`. + +**Interpretation:** `ILMatch` means the assemblies are equivalent under nildiff's configured normalization, filtering, and order-independent IL comparison rules; it does not guarantee that the emitted `*_IL.txt` files are textually identical. If the visible differences are limited to accepted ordering or whitespace changes, this result is expected. If a signature, member body, or containing class differs, retain the two IL files and the disassembler labels from the report when investigating a possible false match. Use `SHA256Match`, rather than `ILMatch`, when byte-for-byte assembly identity is required. + ### All files reported as "Modified" even though they are functionally identical **Symptom:** .NET assemblies show `SHA256Mismatch` instead of `ILMatch`. @@ -640,6 +648,14 @@ New-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" -Name **解釈:** ファイルシステムの更新日時が異なっていても内容は完全に同一であることを示す、想定どおりの結果です。監査手順で更新日時自体を重要情報として扱う場合を除き、対応は不要です。ファイル内容に埋め込まれた日時が実際に異なる場合は通常バイト列も異なるため、`SHA256Match` にはなりません。確認している日時がファイルシステムのメタデータなのか、ファイル内に埋め込まれた情報なのかを切り分けてください。 +### `*_IL.txt` が一致しないのに `ILMatch` になるのはなぜか + +**症状:** `ShouldOutputILText=true` で出力したoldとnewの `*_IL.txt` がテキストとして一致しないのに、Assemblyは `ILMatch` の `Unchanged` として報告される。 + +**原因:** ILテキストファイルには、各Assemblyから得た正規化・フィルタ済みIL行を、そのAssemblyでの行順を保ったまま出力します。比較では最初に行順どおりの一致を確認し、一致しない場合はclass階層を考慮したブロック比較へフォールバックします。この比較では、トップレベルILブロックの並び替え、同じ包含class内にあるmethod・nested class・property・eventの並び替え、行の前後空白差を許容できます。一方、各memberは包含class、シグネチャ、内容の組み合わせで照合するため、method本体の変更、異なるmethod間での本体入れ替え、別classへのmethod移動は `ILMismatch` のままです。 + +**解釈:** `ILMatch` は、nildiffの設定済み正規化、フィルタ、順序非依存IL比較の規則においてAssemblyが同等であることを示し、出力した `*_IL.txt` のテキスト完全一致を保証するものではありません。見えている差が許容対象の並び順または空白だけなら想定どおりです。シグネチャ、member本体、包含classに差がある場合は、誤った一致の調査用に新旧ILファイルとレポートの逆アセンブラ表示を保持してください。Assemblyのバイト単位の同一性が必要な場合は、`ILMatch` ではなく `SHA256Match` を基準にしてください。 + ### 機能的に同一なのにすべてのファイルが「Modified」と報告される **症状:** .NET アセンブリが `ILMatch` ではなく `SHA256Mismatch` と表示される。 From 1967a8e7b331c04ada69eebdf381e4b9711e06a9 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 14 Aug 2026 21:38:04 +0900 Subject: [PATCH 7/8] Record configured IL transformations in audit logs --- CHANGELOG.md | 2 + .../Services/AuditLogGenerateServiceTests.cs | 51 ++++++ .../ILOutputServiceTests.Precompute.cs | 68 ++++++++ .../Services/ILOutputServiceTests.cs | 80 +++++++++ Models/AuditLogEntry.cs | 9 + Models/FileDiffResultLists.Metadata.cs | 23 +++ Models/FileDiffResultLists.cs | 1 + Models/ILTransformationAudit.cs | 36 ++++ Services/AuditLogGenerateService.cs | 14 +- Services/ILOutputService.Comparison.cs | 157 +++++++++++++++++- Services/ILOutputService.cs | 75 +++++++-- USER_GUIDE.md | 4 +- doc/DEVELOPER_GUIDE.md | 16 +- doc/TESTING_GUIDE.md | 4 +- doc/samples/audit_log.json | 20 ++- 15 files changed, 520 insertions(+), 40 deletions(-) create mode 100644 Models/ILTransformationAudit.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index f1d0df1d..1e1bd87a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), #### Added - **Configurable IL substring normalization** — Added `ShouldILNormalizeContainingConfiguredStrings` and `ILNormalizeContainingStrings`. When enabled, every occurrence of each configured substring is replaced with ``, while all surrounding text on the same IL line remains comparable. 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. +- **Auditable configured IL transformations** — When audit logging is enabled, compared assemblies with effective configured ignore or normalization rules now include `ilTransformations.old` and `ilTransformations.new` evidence. Every effective rule records exactly four fields: its operation-specific application-index `ruleId`, `operation`, configured `pattern`, and `appliedLineCount`; zero-match rules remain visible. The count is the number of raw IL lines to which the rule was applied, with multiple matches on one line counted once. The rule-ID numeric suffix uses four digits as its minimum width and expands for indices above 9999. Ignore-rule evidence is collected before normalization, matching comparison behavior, and uses the same record shape as configured normalization evidence. With IL text output enabled, counts are collected in the existing loop that materializes filtered IL for writing. With IL text output disabled, a separate non-retaining audit pass adds CPU work but preserves the streaming comparison memory profile apart from bounded per-file rule summaries. - **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`). #### Changed @@ -1738,6 +1739,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), #### 追加 - **設定可能な IL 部分文字列正規化** — `ShouldILNormalizeContainingConfiguredStrings` と `ILNormalizeContainingStrings` を追加しました。有効時は、設定した各部分文字列のすべての出現箇所を `` へ置換し、同じIL行にある周辺テキストは比較可能な状態で残します。設定正規化と設定行除外はどちらも、`ldstr` が読み込むプログラム文字列リテラルを含む、すべての IL 命令文脈へ適用します。空でない正規化値の前後空白は正確な一致条件の一部として保持し、空白だけの値は無視します。これは、一致した行全体を比較から除外する従来の `ILIgnoreLineContainingStrings` とは独立した機能です。creatorモードでは設定文字列の正規化を自動的に有効化します。4文字未満の正規化値は、短い無視値と同じコンソール/レポート安全性警告の対象とし、正規のIL内容まで正規化するリスクが分かる文言で表示します。 +- **設定 IL 変換の監査証跡** — 監査ログが有効な場合、実効設定除外規則または正規化規則を持つ比較対象Assemblyへ `ilTransformations.old` / `ilTransformations.new` の証跡を追加しました。各実効規則には、操作別の適用位置を表す `ruleId`、`operation`、設定 `pattern`、`appliedLineCount` の4項目だけを記録し、一致数0の規則も残します。適用件数は規則を適用したraw IL行数であり、同一行の複数一致は1件と数えます。規則IDの数値部は4桁を最小幅とし、9999を超える位置では5桁以上へ拡張します。除外規則の証跡は比較動作と同じく正規化より先に収集し、設定正規化の証跡と同じレコード形式で出力します。ILテキスト出力が有効な場合は、書き出し用フィルタ済みILを実体化する既存ループ内で件数も集計します。無効な場合は、本文を保持しない監査用走査のCPUコストが増えますが、ファイル単位の規則サマリー以外は従来のストリーミング比較と同じメモリ特性を維持します。 - **実逆アセンブラ 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`)が必要です。 #### 変更 diff --git a/FolderDiffIL4DotNet.Tests/Services/AuditLogGenerateServiceTests.cs b/FolderDiffIL4DotNet.Tests/Services/AuditLogGenerateServiceTests.cs index 8bfdff15..85789869 100644 --- a/FolderDiffIL4DotNet.Tests/Services/AuditLogGenerateServiceTests.cs +++ b/FolderDiffIL4DotNet.Tests/Services/AuditLogGenerateServiceTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text.Json; using FolderDiffIL4DotNet.Models; using FolderDiffIL4DotNet.Services; @@ -158,6 +159,56 @@ public void GenerateAuditLog_FileEntriesContainCategoryAndDiffDetail() Assert.Contains("\"diffDetail\": \"SHA256Match\"", json); } + [Fact] + public void GenerateAuditLog_ILTransformations_RecordOldAndNewRuleEvidence() + { + var (oldDir, newDir, reportDir) = MakeDirs("il-transformations"); + _resultLists.AddUnchangedFileRelativePath("lib/utils.dll"); + _resultLists.RecordDiffDetail("lib/utils.dll", FileDiffResultLists.DiffDetailResult.ILMatch, "dotnet-ildasm"); + _resultLists.RecordILTransformationAudit( + "lib/utils.dll", + new ILTransformationAudit + { + Old = new[] + { + new ILRuleApplicationAudit + { + RuleId = "configured-ignore-0001", + Operation = "IgnoreLine", + Pattern = "ignore-me", + AppliedLineCount = 1 + } + }, + New = new[] + { + new ILRuleApplicationAudit + { + RuleId = "configured-normalize-0001", + Operation = "NormalizeSubstring", + Pattern = "normalize-me", + AppliedLineCount = 2 + } + } + }); + + _service.GenerateAuditLog(CreateReportContext(oldDir, newDir, reportDir)); + + using var document = JsonDocument.Parse( + File.ReadAllText(Path.Combine(reportDir, AuditLogGenerateService.AUDIT_LOG_FILE_NAME))); + var entry = Assert.Single(document.RootElement.GetProperty("files").EnumerateArray()); + var transformations = entry.GetProperty("ilTransformations"); + var oldRule = Assert.Single(transformations.GetProperty("old").EnumerateArray()); + Assert.Equal("configured-ignore-0001", oldRule.GetProperty("ruleId").GetString()); + Assert.Equal("IgnoreLine", oldRule.GetProperty("operation").GetString()); + Assert.Equal(1, oldRule.GetProperty("appliedLineCount").GetInt32()); + Assert.Equal(4, oldRule.EnumerateObject().Count()); + var newRule = Assert.Single(transformations.GetProperty("new").EnumerateArray()); + Assert.Equal("configured-normalize-0001", newRule.GetProperty("ruleId").GetString()); + Assert.Equal("normalize-me", newRule.GetProperty("pattern").GetString()); + Assert.Equal(2, newRule.GetProperty("appliedLineCount").GetInt32()); + Assert.Equal(4, newRule.EnumerateObject().Count()); + } + /// /// Verifies that Added/Removed files have empty diffDetail and disassembler fields. /// Added/Removed ファイルの diffDetail と disassembler フィールドが空であることを確認する。 diff --git a/FolderDiffIL4DotNet.Tests/Services/ILOutputServiceTests.Precompute.cs b/FolderDiffIL4DotNet.Tests/Services/ILOutputServiceTests.Precompute.cs index 129fc891..967a69bf 100644 --- a/FolderDiffIL4DotNet.Tests/Services/ILOutputServiceTests.Precompute.cs +++ b/FolderDiffIL4DotNet.Tests/Services/ILOutputServiceTests.Precompute.cs @@ -224,6 +224,55 @@ public async Task DiffDotNetAssembliesAsync_WhenFilteredIlSideIsEmpty_LogsWithou Assert.Contains("WillRetry=False", warning.Message, StringComparison.Ordinal); } + [Fact] + public async Task DiffDotNetAssembliesAsync_WithConfiguredTransformations_RecordsOldAndNewAuditEvidence() + { + var config = new ConfigSettingsBuilder + { + ShouldGenerateAuditLog = true, + ShouldOutputILText = false, + ShouldIgnoreILLinesContainingConfiguredStrings = true, + ILIgnoreLineContainingStrings = new() { "ignore-me" }, + ShouldILNormalizeContainingConfiguredStrings = true, + ILNormalizeContainingStrings = new() { "normalize-me" }, + IgnoredExtensions = new(), + TextFileExtensions = new() + }.Build(); + var resultLists = new FileDiffResultLists(); + 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 AuditedDisassembleService(), + ilCache: null, + logger, + resultLists); + + await service.DiffDotNetAssembliesAsync( + "lib/app.dll", + "/virtual/old", + "/virtual/new", + shouldOutputIlText: false); + + var audit = resultLists.FileRelativePathToILTransformationAudit["lib/app.dll"]; + Assert.Collection( + audit.Old, + application => AssertRuleApplication(application, "configured-ignore-0001", "IgnoreLine", "ignore-me", 1), + application => AssertRuleApplication(application, "configured-normalize-0001", "NormalizeSubstring", "normalize-me", 1)); + Assert.Collection( + audit.New, + application => AssertRuleApplication(application, "configured-ignore-0001", "IgnoreLine", "ignore-me", 1), + application => AssertRuleApplication(application, "configured-normalize-0001", "NormalizeSubstring", "normalize-me", 2)); + } + private sealed class NoOpIlTextOutputService : IILTextOutputService { public Task WriteFullIlTextsAsync(string fileRelativePath, IEnumerable filteredIl1Lines, IEnumerable filteredIl2Lines) @@ -342,5 +391,24 @@ private sealed class FilteredEmptyOldDisassembleService : IDotNetDisassembleServ public Task PrefetchIlCacheAsync(IEnumerable paths, int maxParallel, CancellationToken cancellationToken = default) => Task.CompletedTask; } + + private sealed class AuditedDisassembleService : 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 normalize-me", "ignore-me old", "ret" }; + IReadOnlyList newLines = new[] { "ldstr normalize-me", "ldstr normalize-me", "ignore-me new", "ret" }; + 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 2444c883..9c794762 100644 --- a/FolderDiffIL4DotNet.Tests/Services/ILOutputServiceTests.cs +++ b/FolderDiffIL4DotNet.Tests/Services/ILOutputServiceTests.cs @@ -156,6 +156,8 @@ public void FilterIlLines_IlspyMultilineTypeLibraryTimestamp_WritesSingleStableM var result = ILOutputService.FilterIlLines( lines, false, Array.Empty(), Array.Empty()); + var auditedResult = ILOutputService.FilterIlLinesWithAudit( + lines, false, Array.Empty(), Array.Empty()); var expected = new[] { @@ -164,6 +166,8 @@ public void FilterIlLines_IlspyMultilineTypeLibraryTimestamp_WritesSingleStableM " ret" }; Assert.Equal(expected, result); + Assert.Equal(expected, auditedResult.Lines); + Assert.Empty(auditedResult.Applications); } [Fact] @@ -836,6 +840,82 @@ public void NormalizeIlLine_OverlappingConfiguredValues_FollowsListOrder() Assert.Equal($"ldstr {ILOutputService.CONFIGURED_NORMALIZED_VALUE}", longerFirst); } + [Fact] + [Trait("Category", "Unit")] + public void FilterIlLinesWithAudit_RecordsRuleIdsAndAppliedLineCounts() + { + var lines = new[] + { + "ldstr normalize-me", + "ignore-one normalize-me", + "ignore-one ignore-two", + "ldstr untouched" + }; + + var result = ILOutputService.FilterIlLinesWithAudit( + lines, + shouldIgnoreContainingStrings: true, + new[] { "ignore-one", "ignore-two" }, + new[] { "normalize-me", "missing-value" }); + + Assert.Equal( + new[] { $"ldstr {ILOutputService.CONFIGURED_NORMALIZED_VALUE}", "ldstr untouched" }, + result.Lines); + Assert.Collection( + result.Applications, + application => AssertRuleApplication(application, "configured-ignore-0001", "IgnoreLine", "ignore-one", 2), + application => AssertRuleApplication(application, "configured-ignore-0002", "IgnoreLine", "ignore-two", 1), + application => AssertRuleApplication(application, "configured-normalize-0001", "NormalizeSubstring", "normalize-me", 1), + application => AssertRuleApplication(application, "configured-normalize-0002", "NormalizeSubstring", "missing-value", 0)); + } + + [Fact] + [Trait("Category", "Unit")] + public void CollectIlTransformationAudit_RecordsCountsWithoutFilteredLineOutput() + { + var applications = ILOutputService.CollectIlTransformationAudit( + new[] { "normalize-me normalize-me", "ignore-me", "untouched" }, + shouldIgnoreContainingStrings: true, + new[] { "ignore-me" }, + new[] { "normalize-me" }); + + Assert.Collection( + applications, + application => AssertRuleApplication(application, "configured-ignore-0001", "IgnoreLine", "ignore-me", 1), + application => AssertRuleApplication(application, "configured-normalize-0001", "NormalizeSubstring", "normalize-me", 1)); + } + + [Fact] + [Trait("Category", "Unit")] + public void CollectIlTransformationAudit_RuleIdWidthExpandsBeyondFourDigits() + { + string[] normalizationStrings = Enumerable.Range(1, 10_000) + .Select(index => $"value-{index}") + .ToArray(); + + var applications = ILOutputService.CollectIlTransformationAudit( + Array.Empty(), + shouldIgnoreContainingStrings: false, + Array.Empty(), + normalizationStrings); + + Assert.Equal("configured-normalize-0001", applications[0].RuleId); + Assert.Equal("configured-normalize-10000", applications[^1].RuleId); + } + + private static void AssertRuleApplication( + ILRuleApplicationAudit application, + string ruleId, + string operation, + string pattern, + int appliedLineCount) + { + Assert.Equal(ruleId, application.RuleId); + Assert.Equal(operation, application.Operation); + Assert.Equal(pattern, application.Pattern); + Assert.Equal(appliedLineCount, application.AppliedLineCount); + } + // --- FilterIlLines tests / FilterIlLines テスト --- [Fact] diff --git a/Models/AuditLogEntry.cs b/Models/AuditLogEntry.cs index 5b20c9bf..d73f6450 100644 --- a/Models/AuditLogEntry.cs +++ b/Models/AuditLogEntry.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Text.Json.Serialization; namespace FolderDiffIL4DotNet.Models { @@ -30,6 +31,14 @@ public sealed class AuditLogFileEntry /// IL 比較に使用した逆アセンブラのラベル(該当しない場合は空)。 /// public string Disassembler { get; init; } = string.Empty; + + /// + /// Old/new configured IL ignore and normalization applications. Omitted when no configured + /// IL transformation rules were active for this file. + /// old/new別の設定IL除外・正規化適用記録。このファイルで設定変換規則が有効でない場合は省略。 + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ILTransformationAudit? IlTransformations { get; init; } } /// diff --git a/Models/FileDiffResultLists.Metadata.cs b/Models/FileDiffResultLists.Metadata.cs index b1689db0..af286b31 100644 --- a/Models/FileDiffResultLists.Metadata.cs +++ b/Models/FileDiffResultLists.Metadata.cs @@ -112,6 +112,29 @@ public void RecordNewFileTimestampOlderThanOldWarning(string fileRelativePath, s /// public bool HasAnyILFilterWarning => !ILFilterWarnings.IsEmpty; + /// + /// Configured IL ignore/normalization applications per compared file. + /// 比較ファイルごとの設定IL除外・正規化適用記録。 + /// + public ConcurrentDictionary FileRelativePathToILTransformationAudit { get; } + = new ConcurrentDictionary(StringComparer.Ordinal); + + /// + /// Records configured IL transformations for one file, or removes an empty record. + /// 1ファイル分の設定IL変換を記録し、規則がない空記録は除去します。 + /// + public void RecordILTransformationAudit(string fileRelativePath, ILTransformationAudit audit) + { + ArgumentNullException.ThrowIfNull(audit); + if (audit.Old.Count == 0 && audit.New.Count == 0) + { + FileRelativePathToILTransformationAudit.TryRemove(fileRelativePath, out _); + return; + } + + FileRelativePathToILTransformationAudit[fileRelativePath] = audit; + } + // ────────────────────────────────────────────── // Semantic & dependency analysis // セマンティック・依存関係分析 diff --git a/Models/FileDiffResultLists.cs b/Models/FileDiffResultLists.cs index ab5dbadb..e36f5d1e 100644 --- a/Models/FileDiffResultLists.cs +++ b/Models/FileDiffResultLists.cs @@ -169,6 +169,7 @@ public void ResetAll() FileRelativePathToDependencyChanges.Clear(); FileRelativePathToChangeTags.Clear(); FileRelativePathToSdkVersionDictionary.Clear(); + FileRelativePathToILTransformationAudit.Clear(); DisassemblerAvailability = null; } diff --git a/Models/ILTransformationAudit.cs b/Models/ILTransformationAudit.cs new file mode 100644 index 00000000..f50343e5 --- /dev/null +++ b/Models/ILTransformationAudit.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; + +namespace FolderDiffIL4DotNet.Models +{ + /// + /// Old/new configured IL transformation applications for one compared file. + /// 比較対象1ファイルに対するold/new別の設定IL変換適用記録。 + /// + public sealed class ILTransformationAudit + { + /// Applications on the old-side raw IL. / old側raw ILへの適用記録。 + public IReadOnlyList Old { get; init; } = new List(); + + /// Applications on the new-side raw IL. / new側raw ILへの適用記録。 + public IReadOnlyList New { get; init; } = new List(); + } + + /// + /// Application summary for one configured IL rule on one comparison side. + /// 比較片側における1つの設定IL規則の適用サマリー。 + /// + public sealed class ILRuleApplicationAudit + { + /// Rule identifier derived from operation and effective application index. The numeric part has a minimum width of four digits. / 操作と実効適用位置から生成した規則ID。数値部は4桁を最小幅とする。 + public string RuleId { get; init; } = string.Empty; + + /// Operation name: IgnoreLine or NormalizeSubstring. / 操作名: IgnoreLine または NormalizeSubstring。 + public string Operation { get; init; } = string.Empty; + + /// Configured ordinal substring pattern. / 設定されたordinal部分文字列パターン。 + public string Pattern { get; init; } = string.Empty; + + /// Number of raw IL lines to which this rule was applied. / この規則を適用したraw IL行数。 + public int AppliedLineCount { get; init; } + } +} diff --git a/Services/AuditLogGenerateService.cs b/Services/AuditLogGenerateService.cs index 514700e1..ce128436 100644 --- a/Services/AuditLogGenerateService.cs +++ b/Services/AuditLogGenerateService.cs @@ -211,7 +211,8 @@ private List BuildFileEntries( RelativePath = relPath, Category = CATEGORY_MODIFIED, DiffDetail = diffDetail, - Disassembler = disassembler + Disassembler = disassembler, + IlTransformations = GetILTransformationAudit(relPath) }); } @@ -228,7 +229,8 @@ private List BuildFileEntries( RelativePath = relPath, Category = CATEGORY_UNCHANGED, DiffDetail = diffDetail, - Disassembler = disassembler + Disassembler = disassembler, + IlTransformations = GetILTransformationAudit(relPath) }); } @@ -247,6 +249,14 @@ private List BuildFileEntries( .ToList(); } + private ILTransformationAudit? GetILTransformationAudit(string fileRelativePath) + { + return _fileDiffResultLists.FileRelativePathToILTransformationAudit + .TryGetValue(fileRelativePath, out var audit) + ? audit + : null; + } + private void TryAddAbsolutePathEntry( List entries, string rootFolderAbsolutePath, diff --git a/Services/ILOutputService.Comparison.cs b/Services/ILOutputService.Comparison.cs index 3d03d5cc..b189c1af 100644 --- a/Services/ILOutputService.Comparison.cs +++ b/Services/ILOutputService.Comparison.cs @@ -23,6 +23,8 @@ public sealed partial class ILOutputService 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 string IGNORE_LINE_OPERATION = "IgnoreLine"; + internal const string NORMALIZE_SUBSTRING_OPERATION = "NormalizeSubstring"; internal static IReadOnlyList<(string Disassembler, string Prefix, string Replacement)> BuiltInNormalizationRules { get; } = Array.AsReadOnly( @@ -65,6 +67,8 @@ internal static bool StreamingFilteredSequenceEqual( shouldIgnoreContainingStrings, ilIgnoreContainingStrings, ilNormalizeContainingStrings, + onIgnoreRuleApplied: null, + onNormalizeRuleApplied: null, out string normalized1); bool hasLine2 = TryReadNextFilteredNormalizedLine( lines2, @@ -72,6 +76,8 @@ internal static bool StreamingFilteredSequenceEqual( shouldIgnoreContainingStrings, ilIgnoreContainingStrings, ilNormalizeContainingStrings, + onIgnoreRuleApplied: null, + onNormalizeRuleApplied: null, out string normalized2); bool end1 = !hasLine1; @@ -117,6 +123,8 @@ internal static List FilterIlLines( shouldIgnoreContainingStrings, ilIgnoreContainingStrings, ilNormalizeContainingStrings, + onIgnoreRuleApplied: null, + onNormalizeRuleApplied: null, out string normalizedLine)) { result.Add(normalizedLine); @@ -131,6 +139,8 @@ private static bool TryReadNextFilteredNormalizedLine( bool shouldIgnoreContainingStrings, IReadOnlyCollection ilIgnoreContainingStrings, IReadOnlyCollection ilNormalizeContainingStrings, + Action? onIgnoreRuleApplied, + Action? onNormalizeRuleApplied, out string normalizedLine) { while (lineIndex < lines.Count) @@ -147,16 +157,37 @@ private static bool TryReadNextFilteredNormalizedLine( candidateLine = lines[startIndex]; } + bool excludeCandidate = false; + if (shouldIgnoreContainingStrings) + { + for (int rawLineIndex = startIndex; rawLineIndex < endIndexExclusive; rawLineIndex++) + { + int ruleIndex = 0; + foreach (string target in ilIgnoreContainingStrings) + { + if (lines[rawLineIndex]?.Contains(target, StringComparison.Ordinal) == true) + { + onIgnoreRuleApplied?.Invoke(ruleIndex); + if (rawLineIndex == startIndex) + { + excludeCandidate = true; + } + } + ruleIndex++; + } + } + } + lineIndex = endIndexExclusive; - if (ShouldExcludeIlLine( - lines[startIndex], - shouldIgnoreContainingStrings, - ilIgnoreContainingStrings)) + if (excludeCandidate) { continue; } - normalizedLine = NormalizeIlLine(candidateLine, ilNormalizeContainingStrings); + normalizedLine = NormalizeIlLine( + candidateLine, + ilNormalizeContainingStrings, + onNormalizeRuleApplied); return true; } @@ -252,6 +283,107 @@ private static bool IsIlspyCustomAttributeBlobPayloadLine(string line) return hasByte || tokenLength == 2; } + /// + /// Filters and normalizes IL while counting the raw IL lines to which each configured rule applies. + /// Ignore rules run before normalization rules, matching comparison behavior. + /// 各設定規則を適用したraw IL行数を数えながらILを除外・正規化します。 + /// 比較動作と同じく、除外規則を正規化規則より先に適用します。 + /// + internal static (List Lines, IReadOnlyList Applications) FilterIlLinesWithAudit( + IReadOnlyList lines, + bool shouldIgnoreContainingStrings, + IReadOnlyList ilIgnoreContainingStrings, + IReadOnlyList ilNormalizeContainingStrings) + { + var result = ProcessIlLinesWithAudit( + lines, + shouldIgnoreContainingStrings, + ilIgnoreContainingStrings, + ilNormalizeContainingStrings, + materializeLines: true); + return (result.Lines!, result.Applications); + } + + /// + /// Counts configured-rule applications without retaining filtered IL lines. + /// フィルタ済みIL行を保持せず、設定規則の適用行数だけを集計します。 + /// + internal static IReadOnlyList CollectIlTransformationAudit( + IReadOnlyList lines, + bool shouldIgnoreContainingStrings, + IReadOnlyList ilIgnoreContainingStrings, + IReadOnlyList ilNormalizeContainingStrings) + { + return ProcessIlLinesWithAudit( + lines, + shouldIgnoreContainingStrings, + ilIgnoreContainingStrings, + ilNormalizeContainingStrings, + materializeLines: false).Applications; + } + + private static (List? Lines, IReadOnlyList Applications) ProcessIlLinesWithAudit( + IReadOnlyList lines, + bool shouldIgnoreContainingStrings, + IReadOnlyList ilIgnoreContainingStrings, + IReadOnlyList ilNormalizeContainingStrings, + bool materializeLines) + { + List? result = materializeLines ? new List(lines.Count) : null; + var ignoreApplicationCounts = new int[ilIgnoreContainingStrings.Count]; + var normalizeApplicationCounts = new int[ilNormalizeContainingStrings.Count]; + + int lineIndex = 0; + while (TryReadNextFilteredNormalizedLine( + lines, + ref lineIndex, + shouldIgnoreContainingStrings, + ilIgnoreContainingStrings, + ilNormalizeContainingStrings, + ruleIndex => ignoreApplicationCounts[ruleIndex]++, + ruleIndex => normalizeApplicationCounts[ruleIndex]++, + out string normalizedLine)) + { + result?.Add(normalizedLine); + } + + var applications = new List( + ilIgnoreContainingStrings.Count + ilNormalizeContainingStrings.Count); + for (int i = 0; i < ilIgnoreContainingStrings.Count; i++) + { + applications.Add(CreateRuleApplication( + $"configured-ignore-{i + 1:D4}", + IGNORE_LINE_OPERATION, + ilIgnoreContainingStrings[i], + ignoreApplicationCounts[i])); + } + for (int i = 0; i < ilNormalizeContainingStrings.Count; i++) + { + applications.Add(CreateRuleApplication( + $"configured-normalize-{i + 1:D4}", + NORMALIZE_SUBSTRING_OPERATION, + ilNormalizeContainingStrings[i], + normalizeApplicationCounts[i])); + } + + return (result, applications); + } + + private static ILRuleApplicationAudit CreateRuleApplication( + string ruleId, + string operation, + string pattern, + int appliedLineCount) + { + return new ILRuleApplicationAudit + { + RuleId = ruleId, + Operation = operation, + Pattern = pattern, + AppliedLineCount = appliedLineCount + }; + } + /// /// Splits IL text into lines and filters out excluded lines in a single pass, /// avoiding intermediate list allocations from separate Split → Where → ToList chains. @@ -370,8 +502,16 @@ private static bool ShouldExcludeIlLine( /// 既知のビルド依存 IL 値と設定された一致部分を安定したプレースホルダーへ置換します。 /// internal static string NormalizeIlLine( - string line, + string? line, IReadOnlyCollection ilNormalizeContainingStrings) + { + return NormalizeIlLine(line, ilNormalizeContainingStrings, onConfiguredRuleApplied: null); + } + + private static string NormalizeIlLine( + string? line, + IReadOnlyCollection ilNormalizeContainingStrings, + Action? onConfiguredRuleApplied) { if (line is null) { @@ -389,12 +529,15 @@ internal static string NormalizeIlLine( return normalized; } + int ruleIndex = 0; foreach (string target in ilNormalizeContainingStrings) { - if (!string.IsNullOrWhiteSpace(target)) + if (!string.IsNullOrWhiteSpace(target) && normalized.Contains(target, StringComparison.Ordinal)) { + onConfiguredRuleApplied?.Invoke(ruleIndex); normalized = normalized.Replace(target, CONFIGURED_NORMALIZED_VALUE, StringComparison.Ordinal); } + ruleIndex++; } return normalized; diff --git a/Services/ILOutputService.cs b/Services/ILOutputService.cs index 3603e396..af14b5db 100644 --- a/Services/ILOutputService.cs +++ b/Services/ILOutputService.cs @@ -30,6 +30,7 @@ public sealed partial class ILOutputService : IILOutputService private readonly IILTextOutputService _ilTextOutputService; private readonly IDotNetDisassembleService _dotNetDisassembleService; private readonly ILoggerService _logger; + private readonly FileDiffResultLists? _fileDiffResultLists; /// /// Initializes a new instance of . @@ -41,7 +42,8 @@ public ILOutputService( IILTextOutputService ilTextOutputService, IDotNetDisassembleService dotNetDisassembleService, ILCache? ilCache, - ILoggerService logger) + ILoggerService logger, + FileDiffResultLists? fileDiffResultLists = null) { ArgumentNullException.ThrowIfNull(config); ArgumentNullException.ThrowIfNull(executionContext); @@ -53,6 +55,7 @@ public ILOutputService( _ilCache = ilCache; ArgumentNullException.ThrowIfNull(logger); _logger = logger; + _fileDiffResultLists = fileDiffResultLists; } /// @@ -186,8 +189,22 @@ 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}."); } + bool shouldCollectTransformationAudit = _config.ShouldGenerateAuditLog + && _fileDiffResultLists != null + && (ilIgnoreContainingStrings.Count > 0 || ilNormalizeContainingStrings.Count > 0); if (!shouldOutputIlText) { + if (shouldCollectTransformationAudit) + { + _fileDiffResultLists!.RecordILTransformationAudit( + fileRelativePath, + new ILTransformationAudit + { + Old = CollectIlTransformationAudit(il1Lines, shouldIgnore, ilIgnoreContainingStrings, ilNormalizeContainingStrings), + New = CollectIlTransformationAudit(il2Lines, shouldIgnore, ilIgnoreContainingStrings, ilNormalizeContainingStrings) + }); + } + // 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. // ストリーミング比較: フィルタ済み行リストを実体化せずに行単位でフィルタ・比較する。 @@ -217,10 +234,29 @@ public async Task PrecomputeAsync(IEnumerable filesAbsolutePaths, int ma return (areILsEqual, disassemblerLabel); } - // Materialized path: need full filtered lists for IL text file output. - // 実体化パス: IL テキストファイル出力用にフィルタ済み全行リストが必要。 - var filteredIl1Lines = FilterIlLines(il1Lines, shouldIgnore, ilIgnoreContainingStrings, ilNormalizeContainingStrings); - var filteredIl2Lines = FilterIlLines(il2Lines, shouldIgnore, ilIgnoreContainingStrings, ilNormalizeContainingStrings); + // Materialized path: full lists are required for IL text output. + // 実体化パス: ILテキスト出力のために全行リストが必要。 + List filteredIl1Lines; + List filteredIl2Lines; + if (shouldCollectTransformationAudit) + { + var oldResult = FilterIlLinesWithAudit(il1Lines, shouldIgnore, ilIgnoreContainingStrings, ilNormalizeContainingStrings); + var newResult = FilterIlLinesWithAudit(il2Lines, shouldIgnore, ilIgnoreContainingStrings, ilNormalizeContainingStrings); + filteredIl1Lines = oldResult.Lines; + filteredIl2Lines = newResult.Lines; + _fileDiffResultLists!.RecordILTransformationAudit( + fileRelativePath, + new ILTransformationAudit + { + Old = oldResult.Applications, + New = newResult.Applications + }); + } + else + { + filteredIl1Lines = FilterIlLines(il1Lines, shouldIgnore, ilIgnoreContainingStrings, ilNormalizeContainingStrings); + filteredIl2Lines = FilterIlLines(il2Lines, shouldIgnore, ilIgnoreContainingStrings, ilNormalizeContainingStrings); + } LogEmptyFilteredLineSetIfNeeded( fileRelativePath, file1AbsolutePath, @@ -243,20 +279,23 @@ public async Task PrecomputeAsync(IEnumerable filesAbsolutePaths, int ma // コンパイラによるメソッド・クラスの並び替えを考慮し、ブロック単位比較にフォールバック。 areEqual = BlockAwareSequenceEqual(filteredIl1Lines, filteredIl2Lines); } - try - { - // 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)) + if (shouldOutputIlText) { - // Log error with exception details and re-throw on IL text output failure. - // IL テキスト出力に失敗した場合は例外詳細付きエラーログを出しつつ再スロー。 - _logger.LogMessage(AppLogLevel.Error, - $"{ERROR_FAILED_TO_OUTPUT_IL} for '{fileRelativePath}' (Old='{file1AbsolutePath}', New='{file2AbsolutePath}', {ex.GetType().Name}): {ex.Message}", - shouldOutputMessageToConsole: true, ex); - throw; + try + { + // 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)) + { + // Log error with exception details and re-throw on IL text output failure. + // IL テキスト出力に失敗した場合は例外詳細付きエラーログを出しつつ再スロー。 + _logger.LogMessage(AppLogLevel.Error, + $"{ERROR_FAILED_TO_OUTPUT_IL} for '{fileRelativePath}' (Old='{file1AbsolutePath}', New='{file2AbsolutePath}', {ex.GetType().Name}): {ex.Message}", + shouldOutputMessageToConsole: true, ex); + throw; + } } return (areEqual, disassemblerLabel); } diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 4e62baa5..78b5c149 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -800,7 +800,7 @@ Override only the settings you want to change. For example: ShouldGenerateAuditLog true - When true, generates audit_log.json alongside the diff reports. The JSON file records per-file comparison results, run metadata (app version, computer name, timestamps), summary statistics, and SHA256 integrity hashes of diff_report.md and diff_report.html for tamper detection. Set to false to skip audit log generation. + When true, generates audit_log.json alongside the diff reports. The JSON file records per-file comparison results, run metadata (app version, computer name, timestamps), summary statistics, and SHA256 integrity hashes of diff_report.md and diff_report.html for tamper detection. When configured IL ignore or normalization rules are active for a compared assembly, its ilTransformations.old and ilTransformations.new arrays record four fields per effective rule: ruleId, operation, pattern, and appliedLineCount. appliedLineCount is the number of raw IL lines to which the rule was applied; multiple matches for the same normalization rule on one line count as one applied line. Zero-match rules remain present. Rule IDs use the effective application index separately for each operation, such as configured-ignore-0001 and configured-normalize-0001. The numeric suffix has a minimum width of four digits, not a four-digit maximum: the 10,000th rule is emitted as configured-normalize-10000. Set to false to skip audit log generation. ShouldGenerateSbom @@ -1706,7 +1706,7 @@ JSON Schema ファイル([`doc/config.schema.json`](doc/config.schema.json)) ShouldGenerateAuditLog true - true の場合、差分レポートと合わせて audit_log.json を生成します。JSON ファイルにはファイルごとの比較結果、実行メタデータ(アプリバージョン、マシン名、タイムスタンプ)、サマリー統計、および改竄検知用の diff_report.md / diff_report.html の SHA256 インテグリティハッシュが含まれます。false にすると監査ログ生成をスキップします。 + true の場合、差分レポートと合わせて audit_log.json を生成します。JSON ファイルにはファイルごとの比較結果、実行メタデータ(アプリバージョン、マシン名、タイムスタンプ)、サマリー統計、および改竄検知用の diff_report.md / diff_report.html の SHA256 インテグリティハッシュが含まれます。比較した Assembly に設定 IL 除外規則または正規化規則が有効な場合、ilTransformations.old / ilTransformations.new 配列へ各実効規則の ruleIdoperationpatternappliedLineCount の4項目を記録します。appliedLineCount は規則を適用した raw IL 行数です。同じ正規化規則が1行で複数箇所に一致しても、適用行数は1件と数えます。一致数 0 の規則も記録に残ります。規則 ID は操作ごとの実効適用位置から生成し、例は configured-ignore-0001configured-normalize-0001 です。数値部の4桁は最小幅であり、4桁の上限ではありません。10,000件目は configured-normalize-10000 と出力します。false にすると監査ログ生成をスキップします。 ShouldGenerateSbom diff --git a/doc/DEVELOPER_GUIDE.md b/doc/DEVELOPER_GUIDE.md index 55c3cf32..95f19fef 100644 --- a/doc/DEVELOPER_GUIDE.md +++ b/doc/DEVELOPER_GUIDE.md @@ -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, hierarchy-aware order-independent comparison, optional IL dump writing, IL filter string safety validation | Enforces same disassembler identity; when line order differs, matches blocks by parent class path, member signature, and content hash; `ValidateILIgnoreContainingStrings` and `ValidateILNormalizeContainingStrings` validate configured patterns | +| [`Services/ILOutputService.cs`](../Services/ILOutputService.cs) | IL compare flow, line filtering, hierarchy-aware order-independent comparison, optional IL dump writing, IL filter string safety validation | Enforces same disassembler identity; when line order differs, matches blocks by parent class path, member signature, and content hash; records old/new configured ignore and normalization application counts for audit-log generation without retaining filtered IL when IL text output is disabled; `ValidateILIgnoreContainingStrings` and `ValidateILNormalizeContainingStrings` validate configured patterns | | [`Services/ILOutput/ILBlockParser.cs`](../Services/ILOutput/ILBlockParser.cs) | Parses IL disassembly output into logical blocks and comparison-specific class/member blocks | `ParseComparableBlocks` preserves class hierarchy so member order may differ within one class without hiding body changes or cross-class moves | | [`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` | @@ -480,9 +480,9 @@ Why this matters: | [`Services/ReportGenerateService.cs`](../Services/ReportGenerateService.cs) | Markdown report generation | Reads [`FileDiffResultLists`](../Models/FileDiffResultLists.cs) only; iterates `_sectionWriters` via [`IReportSectionWriter`](../Services/IReportSectionWriter.cs) | | [`Services/IReportSectionWriter.cs`](../Services/IReportSectionWriter.cs) + [`Services/ReportWriteContext.cs`](../Services/ReportWriteContext.cs) | Per-section report writing interface and context bag | 10 private nested implementations inside [`ReportGenerateService`](../Services/ReportGenerateService.cs) | | [`Services/HtmlReportGenerateService.cs`](../Services/HtmlReportGenerateService.cs) | Interactive HTML review report generation | Reads [`FileDiffResultLists`](../Models/FileDiffResultLists.cs) only; produces a self-contained [`diff_report.html`](samples/diff_report.html) with checkboxes, text inputs, localStorage auto-save, and download function; uses CSS custom properties (`var(--color-*)`) and utility classes instead of inline styles for theme-aware rendering; supports automatic dark mode via `prefers-color-scheme`; "Download as reviewed" computes SHA256 of the reviewed HTML via Web Crypto API, embeds the hash for self-verification, downloads a companion `.sha256` verification file, and adds a "Verify integrity" button to the reviewed banner; recoverable inline-diff warnings retain whether the skipped source was `TextMismatch` or `ILMismatch`; skipped when [`ShouldGenerateHtmlReport`](../Models/ConfigSettings.cs) is `false` | -| [`Services/AuditLogGenerateService.cs`](../Services/AuditLogGenerateService.cs) | Structured JSON audit log generation | Reads [`FileDiffResultLists`](../Models/FileDiffResultLists.cs) and computes SHA256 integrity hashes of `diff_report.md` / `diff_report.html`; produces [`audit_log.json`](samples/audit_log.json); recoverable warnings retain the reports folder and skipped-entry root path for triage; skipped when [`ShouldGenerateAuditLog`](../Models/ConfigSettings.cs) is `false` | +| [`Services/AuditLogGenerateService.cs`](../Services/AuditLogGenerateService.cs) | Structured JSON audit log generation | Reads [`FileDiffResultLists`](../Models/FileDiffResultLists.cs), including optional per-file old/new configured IL transformation evidence, and computes SHA256 integrity hashes of `diff_report.md` / `diff_report.html`; produces [`audit_log.json`](samples/audit_log.json); recoverable warnings retain the reports folder and skipped-entry root path for triage; skipped when [`ShouldGenerateAuditLog`](../Models/ConfigSettings.cs) is `false` | | [`Services/SbomGenerateService.cs`](../Services/SbomGenerateService.cs) | SBOM (Software Bill of Materials) generation | Extracts component list from [`FileDiffResultLists`](../Models/FileDiffResultLists.cs) with SHA256 hashes and diff status; outputs CycloneDX 1.5 JSON (`sbom.cdx.json`) or SPDX 2.3 JSON (`sbom.spdx.json`); recoverable warnings retain output format plus old/new folder context for skipped components; skipped when [`ShouldGenerateSbom`](../Models/ConfigSettings.cs) is `false` | -| [`Models/AuditLogEntry.cs`](../Models/AuditLogEntry.cs) | Audit log data models | [`AuditLogRecord`](../Models/AuditLogEntry.cs) (top-level), [`AuditLogFileEntry`](../Models/AuditLogEntry.cs) (per-file), [`AuditLogSummary`](../Models/AuditLogEntry.cs) (counts) | +| [`Models/AuditLogEntry.cs`](../Models/AuditLogEntry.cs) + [`Models/ILTransformationAudit.cs`](../Models/ILTransformationAudit.cs) | Audit log data models | Top-level, per-file, summary, and optional old/new configured IL rule-application evidence; each rule records only its operation-specific application-index ID, operation, pattern, and applied raw-IL line count. Multiple matches on one line count once. `D4` is a minimum numeric width, so indices above 9999 expand beyond four digits | | [`Models/SbomModels.cs`](../Models/SbomModels.cs) | SBOM data models | CycloneDX 1.5 models ([`CycloneDxBom`](../Models/SbomModels.cs), [`CycloneDxComponent`](../Models/SbomModels.cs)), SPDX 2.3 models ([`SpdxDocument`](../Models/SbomModels.cs), [`SpdxPackage`](../Models/SbomModels.cs)), [`SbomFormat`](../Models/SbomModels.cs) enum | | [`Services/ProgressReportService.cs`](../Services/ProgressReportService.cs) | Console progress display, phase tracking, elapsed time logging | Phase-based progress numbering (`[current/total]`), keep-alive spinner throttling, fixed-width ETA rendering (`ETA HH:mm (+00 h 12 m)`), dispose-time final phase logging | | [`Services/LoggerService.cs`](../Services/LoggerService.cs) | File and console logging with text/JSON format support | W3C Trace Context (`traceId`/`spanId`), old log file cleanup with retention-context warnings, read-only log file handling, concurrent call safety | @@ -559,7 +559,7 @@ Per-file mechanics: - [`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(...)` 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`, it also replaces each effective [`ILNormalizeContainingStrings`](../Models/ConfigSettings.cs) match with `` 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. + - `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, materialized IL output, and audit collection use the same normalized sequence. Detection prefixes are centralized in [`Constants`](../Common/Constants.cs). If [`ShouldILNormalizeContainingConfiguredStrings`](../Models/ConfigSettings.cs) is `true`, it also replaces each effective [`ILNormalizeContainingStrings`](../Models/ConfigSettings.cs) match with `` 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. - 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. @@ -1440,7 +1440,7 @@ sequenceDiagram | [`Services/IFileSystemService.cs`](../Services/IFileSystemService.cs) + [`Services/FileSystemService.cs`](../Services/FileSystemService.cs) | 列挙/出力系ファイルシステム抽象 | フォルダ単位ユニットテスト向け。遅延列挙もここで扱う | | [`Services/FileDiffService.cs`](../Services/FileDiffService.cs) | ファイル単位の判定木 | `SHA256 -> 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 フィルタ文字列安全性検証 | 同一逆アセンブラ制約を保証;行順序が異なる場合は親classパス、memberシグネチャ、内容ハッシュでブロックを照合;`ValidateILIgnoreContainingStrings` と `ValidateILNormalizeContainingStrings` が設定パターンを検証 | +| [`Services/ILOutputService.cs`](../Services/ILOutputService.cs) | IL 比較、行除外、階層対応の順序非依存比較、任意 IL 出力、IL フィルタ文字列安全性検証 | 同一逆アセンブラ制約を保証;行順序が異なる場合は親classパス、memberシグネチャ、内容ハッシュでブロックを照合;ILテキスト出力無効時はフィルタ済みILを保持せず、監査ログ用にold/new別の設定除外・正規化適用件数を記録;`ValidateILIgnoreContainingStrings` と `ValidateILNormalizeContainingStrings` が設定パターンを検証 | | [`Services/ILOutput/ILBlockParser.cs`](../Services/ILOutput/ILBlockParser.cs) | IL 逆アセンブリ出力を論理ブロックと比較専用のclass/memberブロックへ分割 | `ParseComparableBlocks` がclass階層を保持し、本体変更やclass間移動を隠さずに同一class内のmember順変更を許容 | | [`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` の後処理ステップとして呼び出される | @@ -1463,9 +1463,9 @@ sequenceDiagram | [`Services/ReportGenerateService.cs`](../Services/ReportGenerateService.cs) | Markdown レポート生成 | [`FileDiffResultLists`](../Models/FileDiffResultLists.cs) を読むだけ;`_sectionWriters` を [`IReportSectionWriter`](../Services/IReportSectionWriter.cs) 経由で反復 | | [`Services/IReportSectionWriter.cs`](../Services/IReportSectionWriter.cs) + [`Services/ReportWriteContext.cs`](../Services/ReportWriteContext.cs) | セクション単位のレポート書き込みインターフェイスとコンテキスト | [`ReportGenerateService`](../Services/ReportGenerateService.cs) 内に 10 個のプライベートネストクラスで実装 | | [`Services/HtmlReportGenerateService.cs`](../Services/HtmlReportGenerateService.cs) | インタラクティブ HTML レビューレポート生成 | [`FileDiffResultLists`](../Models/FileDiffResultLists.cs) を読むだけ;チェックボックス・テキスト入力・localStorage 自動保存・ダウンロード機能を持つ自己完結型 [`diff_report.html`](samples/diff_report.html) を生成;インラインスタイルの代わりに CSS カスタムプロパティ(`var(--color-*)`)とユーティリティクラスを使用しテーマ対応レンダリングを実現;`prefers-color-scheme` による自動ダークモード対応;「Download as reviewed」は Web Crypto API でレビュー済み HTML の SHA256 を計算・埋め込み(自己検証用)、コンパニオン `.sha256` 検証ファイルもダウンロードし、レビュー済みバナーに「Verify integrity」ボタンを追加;回復可能な inline-diff warning には skip 元が `TextMismatch` か `ILMismatch` かも残す;[`ShouldGenerateHtmlReport`](../Models/ConfigSettings.cs) が `false` のときはスキップ | -| [`Services/AuditLogGenerateService.cs`](../Services/AuditLogGenerateService.cs) | 構造化 JSON 監査ログ生成 | [`FileDiffResultLists`](../Models/FileDiffResultLists.cs) を読み、`diff_report.md` / `diff_report.html` の SHA256 インテグリティハッシュを計算;[`audit_log.json`](samples/audit_log.json) を生成;回復可能 warning には reports folder と skip された entry の root path も残す;[`ShouldGenerateAuditLog`](../Models/ConfigSettings.cs) が `false` のときはスキップ | +| [`Services/AuditLogGenerateService.cs`](../Services/AuditLogGenerateService.cs) | 構造化 JSON 監査ログ生成 | [`FileDiffResultLists`](../Models/FileDiffResultLists.cs) から任意のファイル単位old/new設定IL変換証跡も読み、`diff_report.md` / `diff_report.html` の SHA256 インテグリティハッシュを計算;[`audit_log.json`](samples/audit_log.json) を生成;回復可能 warning には reports folder と skip された entry の root path も残す;[`ShouldGenerateAuditLog`](../Models/ConfigSettings.cs) が `false` のときはスキップ | | [`Services/SbomGenerateService.cs`](../Services/SbomGenerateService.cs) | SBOM(ソフトウェア部品表)生成 | [`FileDiffResultLists`](../Models/FileDiffResultLists.cs) からコンポーネント一覧を SHA256 ハッシュと差分ステータス付きで抽出;CycloneDX 1.5 JSON(`sbom.cdx.json`)または SPDX 2.3 JSON(`sbom.spdx.json`)を出力;回復可能 warning には出力形式と skip されたコンポーネントの old/new 由来も残す;[`ShouldGenerateSbom`](../Models/ConfigSettings.cs) が `false` のときはスキップ | -| [`Models/AuditLogEntry.cs`](../Models/AuditLogEntry.cs) | 監査ログデータモデル | [`AuditLogRecord`](../Models/AuditLogEntry.cs)(トップレベル)、[`AuditLogFileEntry`](../Models/AuditLogEntry.cs)(ファイル単位)、[`AuditLogSummary`](../Models/AuditLogEntry.cs)(件数集計) | +| [`Models/AuditLogEntry.cs`](../Models/AuditLogEntry.cs) + [`Models/ILTransformationAudit.cs`](../Models/ILTransformationAudit.cs) | 監査ログデータモデル | トップレベル、ファイル単位、件数集計、任意のold/new設定IL規則適用証跡。各規則は操作別の適用位置ID、操作、パターン、適用raw IL行数だけを記録し、同一行の複数一致は1件と数える。`D4` は数値部の最小幅なので、9999を超える位置は5桁以上へ拡張される | | [`Models/SbomModels.cs`](../Models/SbomModels.cs) | SBOM データモデル | CycloneDX 1.5 モデル([`CycloneDxBom`](../Models/SbomModels.cs)、[`CycloneDxComponent`](../Models/SbomModels.cs))、SPDX 2.3 モデル([`SpdxDocument`](../Models/SbomModels.cs)、[`SpdxPackage`](../Models/SbomModels.cs))、[`SbomFormat`](../Models/SbomModels.cs) 列挙型 | | [`Services/ProgressReportService.cs`](../Services/ProgressReportService.cs) | コンソール進捗表示、フェーズ追跡、経過時間ログ | フェーズ番号付き進捗(`[current/total]`)、キープアライブスピナースロットリング、固定長 ETA 表示(`ETA HH:mm (+00 h 12 m)`)、Dispose 時の最終フェーズログ | | [`Services/LoggerService.cs`](../Services/LoggerService.cs) | テキスト/JSON 形式のファイル/コンソールログ | W3C Trace Context(`traceId`/`spanId`)、保持設定文脈付きの古いログファイルクリーンアップ warning、読み取り専用ログファイル対応、並行呼び出し安全性 | @@ -1542,7 +1542,7 @@ flowchart TD - [`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(...)` は [`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` の場合は、実効 [`ILNormalizeContainingStrings`](../Models/ConfigSettings.cs) の一致部分も `` へ置換し、行の残りは比較対象として保持します。空でない正規化値の前後空白は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` の場合は、実効 [`ILNormalizeContainingStrings`](../Models/ConfigSettings.cs) の一致部分も `` へ置換し、行の残りは比較対象として保持します。空でない正規化値の前後空白はordinalな正確部分一致の一部として保持し、空白だけの値は除外します。 - `.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 使用量をログへ残しつつ、実効ワーカー数を下げるか逐次比較へフォールバックします。 diff --git a/doc/TESTING_GUIDE.md b/doc/TESTING_GUIDE.md index 36db2b8e..c8f8e995 100644 --- a/doc/TESTING_GUIDE.md +++ b/doc/TESTING_GUIDE.md @@ -85,7 +85,7 @@ Recommended starting points by change type: - Recoverable warning/logging or malformed-path diagnostics changes: cover both the service-specific warning tests (report/audit/SBOM/checklist/logger) and the shared helper-facing tests in [`DotNetDisassemblerCacheTests`](../FolderDiffIL4DotNet.Tests/Services/Caching/DotNetDisassemblerCacheTests.cs) so rooted/path-like fragments stay aligned. - Project-boundary or reusable-helper changes: start with [`CoreSeparationTests`](../FolderDiffIL4DotNet.Tests/Architecture/CoreSeparationTests.cs) and the relevant tests under [`FolderDiffIL4DotNet.Tests/Core/`](../FolderDiffIL4DotNet.Tests/Core/). - Report wording or section changes: start with [`ReportGenerateServiceTests`](../FolderDiffIL4DotNet.Tests/Services/ReportGenerateServiceTests.cs) and [`HtmlReportGenerateServiceTests`](../FolderDiffIL4DotNet.Tests/Services/HtmlReportGenerateServiceTests.cs), including inline-diff warning coverage. -- Audit log changes: start with [`AuditLogGenerateServiceTests`](../FolderDiffIL4DotNet.Tests/Services/AuditLogGenerateServiceTests.cs) for integrity hashes plus recoverable path-handling warning coverage. +- Audit log changes: start with [`AuditLogGenerateServiceTests`](../FolderDiffIL4DotNet.Tests/Services/AuditLogGenerateServiceTests.cs) for integrity hashes, recoverable path-handling warning coverage, and old/new configured IL transformation evidence. Pair it with [`ILOutputServiceTests`](../FolderDiffIL4DotNet.Tests/Services/ILOutputServiceTests.cs) when rule IDs or applied-line counts change. - SBOM generation changes: start with [`SbomGenerateServiceTests`](../FolderDiffIL4DotNet.Tests/Services/SbomGenerateServiceTests.cs) for format-specific output plus recoverable path/hash warning coverage. @@ -339,7 +339,7 @@ Workflow/config files: [`.github/workflows/dotnet.yml`](../.github/workflows/dot - 回復可能 warning/ログや不正パス診断の変更: 各サービス固有の warning テスト(report / audit / SBOM / checklist / logger)に加え、[`DotNetDisassemblerCacheTests`](../FolderDiffIL4DotNet.Tests/Services/Caching/DotNetDisassemblerCacheTests.cs) の共通 helper 観点テストも通し、rooted/path-like 断片がずれないことを確認する - プロジェクト境界や再利用 helper の変更: [`CoreSeparationTests`](../FolderDiffIL4DotNet.Tests/Architecture/CoreSeparationTests.cs) と [`FolderDiffIL4DotNet.Tests/Core/`](../FolderDiffIL4DotNet.Tests/Core/) 配下の対象テスト - レポート文言やセクション変更: [`ReportGenerateServiceTests`](../FolderDiffIL4DotNet.Tests/Services/ReportGenerateServiceTests.cs) と [`HtmlReportGenerateServiceTests`](../FolderDiffIL4DotNet.Tests/Services/HtmlReportGenerateServiceTests.cs) を起点に、inline-diff warning の網羅も確認 -- 監査ログ変更: [`AuditLogGenerateServiceTests`](../FolderDiffIL4DotNet.Tests/Services/AuditLogGenerateServiceTests.cs) を起点に、integrity hash と回復可能な path-handling warning の網羅を確認 +- 監査ログ変更: [`AuditLogGenerateServiceTests`](../FolderDiffIL4DotNet.Tests/Services/AuditLogGenerateServiceTests.cs) を起点に、integrity hash、回復可能な path-handling warning、old/new別の設定IL変換証跡を確認する。規則IDや適用行数を変更する場合は [`ILOutputServiceTests`](../FolderDiffIL4DotNet.Tests/Services/ILOutputServiceTests.cs) も組み合わせる - SBOM 生成変更: [`SbomGenerateServiceTests`](../FolderDiffIL4DotNet.Tests/Services/SbomGenerateServiceTests.cs) を起点に、形式別出力と回復可能な path/hash warning の網羅を確認 diff --git a/doc/samples/audit_log.json b/doc/samples/audit_log.json index 19b08ab7..93701597 100644 --- a/doc/samples/audit_log.json +++ b/doc/samples/audit_log.json @@ -151,7 +151,25 @@ "relativePath": "util/Helper.dll", "category": "Unchanged", "diffDetail": "ILMatch", - "disassembler": "dotnet-ildasm (version: 0.12.2)" + "disassembler": "dotnet-ildasm (version: 0.12.2)", + "ilTransformations": { + "old": [ + { + "ruleId": "configured-normalize-0001", + "operation": "NormalizeSubstring", + "pattern": "buildserver1_", + "appliedLineCount": 2 + } + ], + "new": [ + { + "ruleId": "configured-normalize-0001", + "operation": "NormalizeSubstring", + "pattern": "buildserver1_", + "appliedLineCount": 2 + } + ] + } }, { "relativePath": "vendor/lib.dll", From 9f1df6024c17f8c6cfd078bbcaad1c83a971842d Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 14 Aug 2026 23:58:50 +0900 Subject: [PATCH 8/8] Update vulnerable JavaScript dependencies --- package-lock.json | 18 +++++++++--------- package.json | 5 +++++ 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 19908e6f..be8b4bd5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1782,9 +1782,9 @@ } }, "node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -3502,9 +3502,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", - "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", + "version": "3.15.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", + "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", "dev": true, "license": "MIT", "dependencies": { @@ -4459,9 +4459,9 @@ } }, "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 0dec28be..068b0c22 100644 --- a/package.json +++ b/package.json @@ -20,5 +20,10 @@ "devDependencies": { "jest": "^30.4.2", "jest-environment-jsdom": "^30.4.1" + }, + "overrides": { + "brace-expansion@<1.1.18": "1.1.18", + "brace-expansion@>=2.0.0 <2.1.4": "2.1.4", + "js-yaml@>=3.0.0 <3.15.1": "3.15.1" } }