diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7534af67..2888c16a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
### [Unreleased]
+#### 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. The parser performs one forward scan with an explicit stack, avoiding recursive subtree rescans/copies and remaining safe for deeply nested classes. Comparison uses a fixed-size hierarchy key derived incrementally from each parent key and class signature, so the production comparison path does not materialize every full ancestor string. 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 plus the complete comparison path at 10,000-level nesting.
+
+#### Fixed
+
+- **IL method headers with arbitrary marshal blobs are parsed completely** — Block comparison now distinguishes a method body's structural braces from braces nested inside `marshal({ ... })` header syntax. Multiline marshal blobs remain attached to their method signature and body, so moving ABI-relevant marshal data between methods is detected while whole-method reordering remains order-independent.
+- **Multiline class declarations retain distinct hierarchy identities** — Member container keys now use the complete class header instead of only its first `.class` line. Classes whose type names appear on continuation lines can no longer share one member bucket, so moving or swapping method bodies between them is detected without changing the public first-line `ContainerPath` display.
+- **Order-sensitive interface declarations remain ordered** — Direct members of IL types marked `interface` or `import` now stay in their class shell instead of being compared as an unordered multiset. Method order changes that can alter COM vtable slots are therefore reported, while ordinary class method reordering and method reordering inside ordinary nested classes remain order-independent.
+
### [2.0.0] - 2026-08-18
#### Added
@@ -1738,6 +1748,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
### [Unreleased]
+#### 変更
+
+- **class内ILメンバー順を階層付きで比較** — 順序非依存IL比較では、classをmemberブロックへ分解し、親classパス、memberシグネチャ、内容ハッシュの組み合わせで照合するようにしました。parserは明示stackによる1回のforward scanを行い、再帰的なsubtree再走査/copyを避け、深い入れ子でも安全に処理します。比較では親keyとclass signatureから増分導出する固定長の階層keyを使い、本番比較経路でも祖先path全文を全block分実体化しません。同じclass内でmethod順だけが変わっても誤った差分になりません。一方、method本体の変更、異なるmethod間での本体入れ替え、別classへのmethod移動は引き続き差分になります。実際的な `dotnet-ildasm` と `ilspycmd` のclass/method配置に加え、10,000階層の入れ子を完全な比較経路で回帰テストしています。
+
+#### 修正
+
+- **任意marshal blobを含むIL method headerを完全に解析** — block比較で、method本体の構造波括弧と `marshal({ ... })` header構文内の波括弧を区別するようにしました。複数行marshal blobを対応するmethod signature/本体と同じblockへ保持するため、method全体の並び替えは従来どおり許容しながら、ABIに関わるmarshalデータのmethod間移動を検出します。
+- **複数行class宣言の階層identityを区別** — memberのcontainer keyに、先頭の `.class` 行だけでなく完全なclass headerを使用するようにしました。型名が継続行にあるclass同士でmember bucketを共有しなくなるため、公開される先頭行形式の `ContainerPath` 表示を変えずに、class間のmethod本体移動や入れ替えを検出します。
+- **順序に意味があるinterface宣言を順序どおり保持** — IL typeに `interface` または `import` が指定されている場合、その直接memberを順序非依存のmultisetへ分解せずclass shell内へ保持するようにしました。COM vtable slotを変え得るmethod順変更を差分として報告しつつ、通常classおよびその通常nested class内のmethod並び替えは従来どおり許容します。
+
### [2.0.0] - 2026-08-18
#### 追加
diff --git a/FolderDiffIL4DotNet.Core/FolderDiffIL4DotNet.Core.csproj b/FolderDiffIL4DotNet.Core/FolderDiffIL4DotNet.Core.csproj
index 05509f72..abf06c9a 100644
--- a/FolderDiffIL4DotNet.Core/FolderDiffIL4DotNet.Core.csproj
+++ b/FolderDiffIL4DotNet.Core/FolderDiffIL4DotNet.Core.csproj
@@ -29,6 +29,11 @@
+
+
+
+
+
diff --git a/FolderDiffIL4DotNet.Core/IL/ILBlockParser.cs b/FolderDiffIL4DotNet.Core/IL/ILBlockParser.cs
index 989ab71f..5cb4f80a 100644
--- a/FolderDiffIL4DotNet.Core/IL/ILBlockParser.cs
+++ b/FolderDiffIL4DotNet.Core/IL/ILBlockParser.cs
@@ -1,8 +1,106 @@
using System;
using System.Collections.Generic;
+using System.Security.Cryptography;
+using System.Text;
namespace FolderDiffIL4DotNet.Core.IL
{
+ // Linked nodes keep deep class paths compact until the public display value is requested.
+ // Each digest uses the complete class header while Signature preserves the first-line display format.
+ // 連結nodeにより、公開表示値が要求されるまで深いclass pathをコンパクトに保持します。
+ // 各digestには完全なclass headerを使い、Signatureは従来の先頭行表示形式を維持します。
+ internal sealed class ILContainerPath
+ {
+ private readonly byte[] _comparisonDigest;
+ private string? _comparisonKey;
+ private string? _value;
+
+ internal ILContainerPath(
+ ILContainerPath? parent,
+ string signature,
+ string comparisonIdentity)
+ {
+ Parent = parent;
+ Signature = signature;
+ _comparisonDigest = CreateComparisonDigest(parent, comparisonIdentity);
+ }
+
+ internal ILContainerPath? Parent { get; }
+
+ internal string Signature { get; }
+
+ internal string GetComparisonKey()
+ {
+ return _comparisonKey ??= Convert.ToHexString(_comparisonDigest);
+ }
+
+ internal string GetValue()
+ {
+ if (_value != null)
+ {
+ return _value;
+ }
+
+ var signatures = new Stack();
+ for (ILContainerPath? current = this; current != null; current = current.Parent)
+ {
+ signatures.Push(current.Signature);
+ }
+
+ _value = string.Join("\n", signatures);
+ return _value;
+ }
+
+ private static byte[] CreateComparisonDigest(ILContainerPath? parent, string comparisonIdentity)
+ {
+ byte[] signatureBytes = Encoding.UTF8.GetBytes(comparisonIdentity);
+ int parentDigestLength = parent?._comparisonDigest.Length ?? 0;
+ var payload = new byte[1 + parentDigestLength + signatureBytes.Length];
+ payload[0] = parent == null ? (byte)0 : (byte)1;
+ if (parent != null)
+ {
+ parent._comparisonDigest.CopyTo(payload, 1);
+ }
+ signatureBytes.CopyTo(payload, 1 + parentDigestLength);
+ return SHA256.HashData(payload);
+ }
+ }
+
+ ///
+ /// A logical IL block paired with the containing class path used for comparison.
+ /// 比較に使用する包含class pathを伴う論理ILブロックです。
+ ///
+ public sealed class ILComparableBlock
+ {
+ private readonly ILContainerPath? _containerPath;
+
+ internal ILComparableBlock(ILContainerPath? containerPath, List lines)
+ {
+ _containerPath = containerPath;
+ // Parser-owned lists are not mutated after this ownership handoff.
+ // parser所有listは、この所有権移譲後には変更されません。
+ Lines = lines.AsReadOnly();
+ }
+
+ ///
+ /// Containing class signature path, or empty for top-level blocks.
+ /// 包含class signature path。トップレベルブロックでは空です。
+ ///
+ public string ContainerPath => _containerPath?.GetValue() ?? string.Empty;
+
+ ///
+ /// Fixed-size hierarchy identity used internally for comparison without materializing every full path.
+ /// 全pathを実体化せず比較するために内部で使用する固定長の階層identityです。
+ ///
+ internal string ContainerComparisonKey => _containerPath?.GetComparisonKey() ?? string.Empty;
+
+ ///
+ /// 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
@@ -38,6 +136,8 @@ public static List> ParseBlocks(IReadOnlyList lines)
var blocks = new List>();
var currentBlock = new List(); // preamble / プリアンブル
int braceDepth = 0;
+ int headerParenthesisDepth = 0;
+ bool sawOpeningBrace = false;
bool inBlock = false;
for (int i = 0; i < lines.Count; i++)
@@ -56,17 +156,25 @@ public static List> ParseBlocks(IReadOnlyList lines)
}
inBlock = true;
braceDepth = 0;
+ headerParenthesisDepth = 0;
+ sawOpeningBrace = false;
}
currentBlock.Add(line);
if (inBlock)
{
- // Count braces to detect block end
- // 波括弧を数えてブロック終了を検出
- braceDepth += CountBraces(trimmed);
+ // Only a top-level header brace starts the body. Header constructs such as
+ // marshal({ ... }) have braces nested inside parentheses and are not delimiters.
+ // headerの最上位波括弧だけを本体開始とします。marshal({ ... })のように
+ // 丸括弧内へnestedしたheader構文の波括弧は区切りではありません。
+ UpdateBlockBraceState(
+ trimmed,
+ ref sawOpeningBrace,
+ ref braceDepth,
+ ref headerParenthesisDepth);
- if (braceDepth <= 0 && trimmed.StartsWith("}", StringComparison.Ordinal))
+ if (sawOpeningBrace && braceDepth <= 0)
{
// Block ended — save it and start new inter-block collection
// ブロック終了 — 保存して新しいブロック間コレクションを開始
@@ -74,6 +182,8 @@ public static List> ParseBlocks(IReadOnlyList lines)
currentBlock = new List();
inBlock = false;
braceDepth = 0;
+ headerParenthesisDepth = 0;
+ sawOpeningBrace = false;
}
}
}
@@ -88,6 +198,278 @@ public static List> ParseBlocks(IReadOnlyList lines)
return blocks;
}
+ ///
+ /// Parses IL into comparison blocks while preserving class/member hierarchy.
+ /// An ordinary class is represented by a shell block without its directly nested reorderable members;
+ /// each member is emitted separately with the containing class signature path. Interface and imported-type
+ /// members remain in declaration order in their class shell. Comparison identity includes every line of
+ /// each class header, including multiline type names and base declarations.
+ /// class/member階層を保持した比較用ブロックへILを解析します。
+ /// 通常classは直接包含する並び替え可能memberを除いたshell blockとして表し、各memberは包含class
+ /// signature path付きで個別に出力します。interfaceとimport typeのmemberは宣言順のままclass shellへ
+ /// 保持します。比較identityには、複数行の型名やbase宣言を含む各class headerの全行を使用します。
+ ///
+ 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, null, comparableBlocks);
+ }
+ else
+ {
+ comparableBlocks.Add(new ILComparableBlock(null, block));
+ }
+ }
+
+ return comparableBlocks;
+ }
+
+ private static void AddClassAndMemberBlocks(
+ IReadOnlyList classLines,
+ ILContainerPath? parentPath,
+ List result)
+ {
+ var frames = new Stack();
+ frames.Push(ComparableBlockFrame.CreateClass(parentPath, canEndBeforeOpeningBrace: false));
+
+ // Each line is assigned exactly once to a class shell or direct member. Nested classes suspend
+ // their parent frame on this explicit stack, avoiding subtree rescans, copies, and recursion.
+ // 各行をclass shellまたは直接memberのどちらかへ一度だけ割り当てます。nested class解析中は
+ // 親frameを明示stackで保留し、subtreeの再走査・コピー・再帰を回避します。
+ int lineIndex = 0;
+ while (lineIndex < classLines.Count && frames.Count > 0)
+ {
+ ComparableBlockFrame frame = frames.Peek();
+ string line = classLines[lineIndex];
+ string trimmed = line.TrimStart();
+
+ // Multiline signatures without an opening brace end immediately before the next sibling
+ // or the owning class close, matching the previous malformed-input behavior.
+ // 開始波括弧のない複数行signatureは、次のsiblingまたは所有classの閉じ波括弧直前で終了し、
+ // 従来の不正入力に対する挙動を維持します。
+ if (frame.CanEndBeforeOpeningBrace &&
+ !frame.SawOpeningBrace &&
+ frame.HeaderParenthesisDepth == 0 &&
+ (trimmed.StartsWith("}", StringComparison.Ordinal) || IsReorderableClassMemberStart(trimmed)))
+ {
+ CompleteComparableBlock(frames.Pop(), result);
+ continue;
+ }
+
+ // Depth 1 identifies direct members; deeper directives belong to the current member body.
+ // depth 1だけを直接memberとして扱い、それより深いdirectiveは現在のmember本体に残します。
+ if (frame.IsClass &&
+ frame.BraceDepth == 1 &&
+ IsReorderableClassMemberStart(trimmed) &&
+ (!frame.PreserveDirectMemberOrder || trimmed.StartsWith(".class ", StringComparison.Ordinal)))
+ {
+ ComparableBlockFrame childFrame;
+ if (trimmed.StartsWith(".class ", StringComparison.Ordinal))
+ {
+ childFrame = ComparableBlockFrame.CreateClass(
+ frame.ClassPath,
+ canEndBeforeOpeningBrace: true);
+ }
+ else
+ {
+ childFrame = ComparableBlockFrame.CreateMember(frame.ClassPath);
+ }
+
+ childFrame.AddLine(line);
+ frames.Push(childFrame);
+ lineIndex++;
+ continue;
+ }
+
+ frame.AddLine(line);
+ lineIndex++;
+
+ // Once a body starts, its matching brace completes the frame, including nested scopes.
+ // 本体開始後は、nested scopeを含めて対応する閉じ波括弧でframeを完了します。
+ if (frame.CanEndBeforeOpeningBrace && frame.SawOpeningBrace && frame.BraceDepth <= 0)
+ {
+ CompleteComparableBlock(frames.Pop(), result);
+ }
+ }
+
+ // Preserve the previous behavior for an unclosed final member or class by consuming to EOF.
+ // 閉じられていない末尾memberまたはclassはEOFまで取り込む従来挙動を維持します。
+ while (frames.Count > 0)
+ {
+ CompleteComparableBlock(frames.Pop(), result);
+ }
+ }
+
+ private static void CompleteComparableBlock(
+ ComparableBlockFrame frame,
+ List result)
+ {
+ result.Add(new ILComparableBlock(frame.ContainerPath, frame.Lines));
+ }
+
+ private sealed class ComparableBlockFrame
+ {
+ private int _braceDepth;
+ private int _headerParenthesisDepth;
+ private bool _sawOpeningBrace;
+ private List? _classHeaderIdentityLines;
+ private ILContainerPath? _classPath;
+
+ private ComparableBlockFrame(
+ bool isClass,
+ bool canEndBeforeOpeningBrace,
+ ILContainerPath? containerPath,
+ ILContainerPath? classPath)
+ {
+ IsClass = isClass;
+ CanEndBeforeOpeningBrace = canEndBeforeOpeningBrace;
+ ContainerPath = containerPath;
+ _classPath = classPath;
+ _classHeaderIdentityLines = isClass ? new List() : null;
+ Lines = new List();
+ }
+
+ internal bool IsClass { get; }
+
+ internal bool CanEndBeforeOpeningBrace { get; }
+
+ internal ILContainerPath? ContainerPath { get; }
+
+ internal ILContainerPath ClassPath => _classPath ??
+ throw new InvalidOperationException("The class path is unavailable before its header is complete.");
+
+ internal List Lines { get; }
+
+ internal int BraceDepth => _braceDepth;
+
+ internal bool SawOpeningBrace => _sawOpeningBrace;
+
+ internal int HeaderParenthesisDepth => _headerParenthesisDepth;
+
+ internal bool PreserveDirectMemberOrder { get; private set; }
+
+ internal static ComparableBlockFrame CreateClass(
+ ILContainerPath? containerPath,
+ bool canEndBeforeOpeningBrace)
+ {
+ return new ComparableBlockFrame(
+ isClass: true,
+ canEndBeforeOpeningBrace,
+ containerPath,
+ classPath: null);
+ }
+
+ internal static ComparableBlockFrame CreateMember(ILContainerPath containerPath)
+ {
+ return new ComparableBlockFrame(
+ isClass: false,
+ canEndBeforeOpeningBrace: true,
+ containerPath,
+ containerPath);
+ }
+
+ internal void AddLine(string line)
+ {
+ Lines.Add(line);
+ string trimmed = line.TrimStart();
+ int bodyOpeningBraceIndex = UpdateBlockBraceState(
+ trimmed,
+ ref _sawOpeningBrace,
+ ref _braceDepth,
+ ref _headerParenthesisDepth);
+
+ if (IsClass && _classPath == null)
+ {
+ string headerFragment = bodyOpeningBraceIndex >= 0
+ ? trimmed.Substring(0, bodyOpeningBraceIndex).Trim()
+ : trimmed.Trim();
+ if (headerFragment.Length > 0)
+ {
+ _classHeaderIdentityLines!.Add(headerFragment);
+ }
+
+ if (bodyOpeningBraceIndex >= 0)
+ {
+ string displaySignature = ExtractBlockSignature(Lines).Trim();
+ PreserveDirectMemberOrder =
+ ContainsClassHeaderToken(_classHeaderIdentityLines!, "interface") ||
+ ContainsClassHeaderToken(_classHeaderIdentityLines!, "import");
+ string comparisonIdentity = string.Join("\n", _classHeaderIdentityLines!);
+ _classPath = new ILContainerPath(
+ ContainerPath,
+ displaySignature,
+ comparisonIdentity);
+ _classHeaderIdentityLines = null;
+ }
+ }
+ }
+ }
+
+ private static bool ContainsClassHeaderToken(
+ IReadOnlyList headerLines,
+ string expectedToken)
+ {
+ foreach (string line in headerLines)
+ {
+ int index = 0;
+ while (index < line.Length)
+ {
+ while (index < line.Length && char.IsWhiteSpace(line[index]))
+ {
+ index++;
+ }
+
+ if (index >= line.Length ||
+ (line[index] == '/' && index + 1 < line.Length && line[index + 1] == '/'))
+ {
+ break;
+ }
+
+ if (line[index] == '\'' || line[index] == '"')
+ {
+ char quote = line[index++];
+ while (index < line.Length)
+ {
+ if (line[index] == quote && !IsEscaped(line, index))
+ {
+ index++;
+ break;
+ }
+ index++;
+ }
+ continue;
+ }
+
+ int tokenStart = index;
+ while (index < line.Length && !char.IsWhiteSpace(line[index]))
+ {
+ index++;
+ }
+
+ int tokenLength = index - tokenStart;
+ if (tokenLength == expectedToken.Length &&
+ string.CompareOrdinal(line, tokenStart, expectedToken, 0, tokenLength) == 0)
+ {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ 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.
@@ -131,56 +513,129 @@ private static bool IsBlockStart(string trimmedLine)
return false;
}
+ ///
+ /// Updates body-brace state while ignoring braces nested in a declaration header's parentheses.
+ /// declaration headerの丸括弧内にnestedした波括弧を無視しながら、本体の波括弧状態を更新します。
+ ///
+ private static int UpdateBlockBraceState(
+ string line,
+ ref bool sawOpeningBrace,
+ ref int braceDepth,
+ ref int headerParenthesisDepth)
+ {
+ if (sawOpeningBrace)
+ {
+ braceDepth += CountBraces(line, 0);
+ return -1;
+ }
+
+ int bodyOpeningBraceIndex = FindBodyOpeningBrace(line, ref headerParenthesisDepth);
+ if (bodyOpeningBraceIndex < 0)
+ {
+ return -1;
+ }
+
+ sawOpeningBrace = true;
+ braceDepth += CountBraces(line, bodyOpeningBraceIndex);
+ return bodyOpeningBraceIndex;
+ }
+
+ private static int FindBodyOpeningBrace(string line, ref int parenthesisDepth)
+ {
+ char quote = '\0';
+
+ for (int i = 0; i < line.Length; i++)
+ {
+ char c = line[i];
+ if (quote == '\0' && c == '/' && i + 1 < line.Length && line[i + 1] == '/')
+ {
+ break;
+ }
+
+ if (c == '\'' || c == '"')
+ {
+ if (quote == '\0')
+ {
+ quote = c;
+ }
+ else if (quote == c && !IsEscaped(line, i))
+ {
+ quote = '\0';
+ }
+ continue;
+ }
+
+ if (quote != '\0')
+ {
+ continue;
+ }
+
+ if (c == '(')
+ {
+ parenthesisDepth++;
+ }
+ else if (c == ')' && parenthesisDepth > 0)
+ {
+ parenthesisDepth--;
+ }
+ else if (c == '{' && parenthesisDepth == 0)
+ {
+ return i;
+ }
+ }
+
+ return -1;
+ }
+
///
/// Counts the net brace change in a line (opening minus closing),
- /// skipping braces inside string literals ("...") and after line comments (//).
+ /// skipping braces inside string literals ("..."), single-quoted identifiers, and after line comments (//).
/// This prevents false block boundary detection from braces in IL string operands
/// (e.g. ldstr "JSON: {\"key\": \"value\"}") or comments.
/// 行中の波括弧の差分(開き - 閉じ)を数えます。
- /// 文字列リテラル("...")内およびラインコメント(//)以降の波括弧はスキップします。
+ /// 文字列リテラル("...")内、single quote identifier内、およびラインコメント(//)以降の波括弧はスキップします。
/// IL 文字列オペランド(例: ldstr "JSON: {\"key\": \"value\"}")や
/// コメント内の波括弧によるブロック境界の誤検知を防止します。
///
private static int CountBraces(string line)
+ {
+ return CountBraces(line, 0);
+ }
+
+ private static int CountBraces(string line, int startIndex)
{
int count = 0;
- bool inString = false;
+ char quote = '\0';
- for (int i = 0; i < line.Length; i++)
+ for (int i = startIndex; i < line.Length; i++)
{
char c = line[i];
- // Check for line comment start (outside of string literals)
- // 文字列リテラル外でのラインコメント開始をチェック
- if (!inString && c == '/' && i + 1 < line.Length && line[i + 1] == '/')
+ // Check for line comment start (outside of quoted values)
+ // quoteされた値の外でのラインコメント開始をチェック
+ if (quote == '\0' && c == '/' && i + 1 < line.Length && line[i + 1] == '/')
{
// Rest of line is a comment — no more braces to count
// 行の残りはコメント — これ以上波括弧をカウントしない
break;
}
- // Track string literal boundaries (handle escaped quotes)
- // 文字列リテラルの境界を追跡(エスケープされた引用符を処理)
- if (c == '"')
+ // Track double-quoted strings and single-quoted IL identifiers.
+ // double quote文字列とsingle quote IL identifierを追跡します。
+ if (c == '\'' || c == '"')
{
- if (inString)
+ if (quote == '\0')
{
- // Check if this quote is escaped by a backslash
- // この引用符がバックスラッシュでエスケープされているかチェック
- int backslashCount = 0;
- for (int j = i - 1; j >= 0 && line[j] == '\\'; j--)
- backslashCount++;
- if (backslashCount % 2 == 0)
- inString = false; // Unescaped quote — end of string / エスケープされていない引用符 — 文字列終了
+ quote = c;
}
- else
+ else if (quote == c && !IsEscaped(line, i))
{
- inString = true;
+ quote = '\0';
}
continue;
}
- if (!inString)
+ if (quote == '\0')
{
if (c == '{') count++;
else if (c == '}') count--;
@@ -188,5 +643,16 @@ private static int CountBraces(string line)
}
return count;
}
+
+ private static bool IsEscaped(string line, int characterIndex)
+ {
+ int backslashCount = 0;
+ for (int i = characterIndex - 1; i >= 0 && line[i] == '\\'; i--)
+ {
+ backslashCount++;
+ }
+
+ return backslashCount % 2 != 0;
+ }
}
}
diff --git a/FolderDiffIL4DotNet.Tests/Services/ILBlockParserTests.cs b/FolderDiffIL4DotNet.Tests/Services/ILBlockParserTests.cs
index 477c7787..a2000285 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,206 @@ public void ParseBlocks_ClassBlock_ParsedCorrectly()
Assert.Equal(8, result[0].Count);
}
+ [Fact]
+ [Trait("Category", "Unit")]
+ public void ParseBlocks_MultilineMarshalHeaders_KeepCompleteMethodBlocks()
+ {
+ var lines = new List
+ {
+ ".method public hidebysig",
+ " instance void",
+ " marshal({",
+ " 38 01 02 FF",
+ " })",
+ " Foo() cil managed",
+ "{",
+ " ret",
+ "}",
+ ".method public hidebysig",
+ " instance void",
+ " marshal ( {",
+ " 39 02 03 EE",
+ " } )",
+ " Bar() cil managed",
+ "{",
+ " ret",
+ "}"
+ };
+
+ var result = ILBlockParser.ParseBlocks(lines);
+
+ Assert.Equal(2, result.Count);
+ Assert.Contains(" Foo() cil managed", result[0]);
+ Assert.DoesNotContain(" Bar() cil managed", result[0]);
+ Assert.Contains(" Bar() cil managed", result[1]);
+ }
+
+ [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");
+ }
+
+ [Fact]
+ [Trait("Category", "Unit")]
+ public void ParseComparableBlocks_MultilineClassHeader_PreservesDisplayedContainerPath()
+ {
+ const string firstClassLine = ".class public auto ansi";
+ var lines = new List
+ {
+ firstClassLine,
+ " MyClass",
+ " extends [System.Runtime]System.Object",
+ "{",
+ " .method public void Foo() cil managed",
+ " {",
+ " ret",
+ " }",
+ "}"
+ };
+
+ var result = CoreParser.ParseComparableBlocks(lines);
+
+ var member = Assert.Single(result, block =>
+ CoreParser.ExtractBlockSignature(block.Lines) == ".method public void Foo() cil managed");
+ Assert.Equal(firstClassLine, member.ContainerPath);
+ }
+
+ [Fact]
+ [Trait("Category", "Unit")]
+ public void ParseComparableBlocks_Interface_PreservesDirectMemberOrderInClassShell()
+ {
+ const string firstMethod =
+ " .method public hidebysig newslot abstract virtual instance void First() cil managed";
+ const string secondMethod =
+ " .method public hidebysig newslot abstract virtual instance void Second() cil managed";
+ var lines = new List
+ {
+ ".class public interface abstract auto ansi IComContract",
+ "{",
+ firstMethod,
+ " {",
+ " }",
+ secondMethod,
+ " {",
+ " }",
+ "}"
+ };
+
+ var result = CoreParser.ParseComparableBlocks(lines);
+
+ var classShell = Assert.Single(result);
+ var shellLines = classShell.Lines.ToList();
+ Assert.True(shellLines.IndexOf(firstMethod) < shellLines.IndexOf(secondMethod));
+ }
+
+ [Fact]
+ [Trait("Category", "Unit")]
+ public void ParseComparableBlocks_NestedClasses_PreserveFullHierarchyAndShells()
+ {
+ const string outerClass = ".class public Outer";
+ const string nestedClass = ".class nested public Nested";
+ const string deepClass = ".class nested public Deep";
+ var lines = new List
+ {
+ outerClass,
+ "{",
+ $" {nestedClass}",
+ " {",
+ " .method public void NestedMethod() cil managed",
+ " {",
+ " ret",
+ " }",
+ $" {deepClass}",
+ " {",
+ " .method public void DeepMethod() cil managed",
+ " {",
+ " ret",
+ " }",
+ " }",
+ " }",
+ " .method public void OuterMethod() cil managed",
+ " {",
+ " ret",
+ " }",
+ "}"
+ };
+
+ var result = CoreParser.ParseComparableBlocks(lines);
+
+ Assert.Equal(6, result.Count);
+
+ var deepMember = Assert.Single(result, block =>
+ CoreParser.ExtractBlockSignature(block.Lines) == ".method public void DeepMethod() cil managed");
+ Assert.Equal($"{outerClass}\n{nestedClass}\n{deepClass}", deepMember.ContainerPath);
+
+ var deepShell = Assert.Single(result, block =>
+ CoreParser.ExtractBlockSignature(block.Lines) == deepClass);
+ Assert.Equal($"{outerClass}\n{nestedClass}", deepShell.ContainerPath);
+ Assert.DoesNotContain(deepShell.Lines, line => line.Contains("DeepMethod", System.StringComparison.Ordinal));
+
+ var outerShell = Assert.Single(result, block =>
+ CoreParser.ExtractBlockSignature(block.Lines) == outerClass);
+ Assert.Equal(string.Empty, outerShell.ContainerPath);
+ Assert.DoesNotContain(outerShell.Lines, line => line.Contains("Nested", System.StringComparison.Ordinal));
+ Assert.DoesNotContain(outerShell.Lines, line => line.Contains("OuterMethod", System.StringComparison.Ordinal));
+ }
+
+ [Fact]
+ [Trait("Category", "Unit")]
+ public void ParseComparableBlocks_TenThousandNestedClasses_UsesExplicitStack()
+ {
+ const int depth = 10_000;
+ const string classSignature = ".class C";
+ var lines = new List(depth * 3);
+ for (int i = 0; i < depth; i++)
+ {
+ lines.Add(classSignature);
+ lines.Add("{");
+ }
+ for (int i = 0; i < depth; i++)
+ {
+ lines.Add("}");
+ }
+
+ var result = CoreParser.ParseComparableBlocks(lines);
+
+ Assert.Equal(depth, result.Count);
+ Assert.All(result, block => Assert.Equal(3, block.Lines.Count));
+ Assert.Equal(string.Empty, result[^1].ContainerPath);
+ Assert.Equal(depth - 1, result[0].ContainerPath.Count(character => character == '\n') + 1);
+ }
+
// --- Brace counting resilience tests / 波括弧カウント耐性テスト ---
[Fact]
diff --git a/FolderDiffIL4DotNet.Tests/Services/ILOutputServiceTests.BlockComparison.cs b/FolderDiffIL4DotNet.Tests/Services/ILOutputServiceTests.BlockComparison.cs
new file mode 100644
index 00000000..0fa16fd5
--- /dev/null
+++ b/FolderDiffIL4DotNet.Tests/Services/ILOutputServiceTests.BlockComparison.cs
@@ -0,0 +1,290 @@
+using System.Collections.Generic;
+using FolderDiffIL4DotNet.Services;
+using Xunit;
+
+namespace FolderDiffIL4DotNet.Tests.Services
+{
+ public sealed partial class ILOutputServiceTests
+ {
+ [Fact]
+ [Trait("Category", "Unit")]
+ public void BlockAwareSequenceEqual_TenThousandNestedClasses_UsesCompactContainerKeys()
+ {
+ const int depth = 10_000;
+ var lines = new List(depth * 3);
+ for (int i = 0; i < depth; i++)
+ {
+ lines.Add(".class C");
+ lines.Add("{");
+ }
+ for (int i = 0; i < depth; i++)
+ {
+ lines.Add("}");
+ }
+
+ Assert.True(ILOutputService.BlockAwareSequenceEqual(lines, new List(lines)));
+ }
+
+ [Fact]
+ [Trait("Category", "Unit")]
+ public void BlockAwareSequenceEqual_MarshalBlobsSwappedBetweenMethods_ReturnsFalse()
+ {
+ var lines1 = BuildClassWithMultilineMarshalHeaders(
+ ("Foo", "38 01 02 FF"),
+ ("Bar", "39 02 03 EE"));
+ var lines2 = BuildClassWithMultilineMarshalHeaders(
+ ("Foo", "39 02 03 EE"),
+ ("Bar", "38 01 02 FF"));
+
+ Assert.False(ILOutputService.BlockAwareSequenceEqual(lines1, lines2));
+ }
+
+ [Fact]
+ [Trait("Category", "Unit")]
+ public void BlockAwareSequenceEqual_MethodsWithMarshalBlobsReordered_ReturnsTrue()
+ {
+ var lines1 = BuildClassWithMultilineMarshalHeaders(
+ ("Foo", "38 01 02 FF"),
+ ("Bar", "39 02 03 EE"));
+ var lines2 = BuildClassWithMultilineMarshalHeaders(
+ ("Bar", "39 02 03 EE"),
+ ("Foo", "38 01 02 FF"));
+
+ Assert.True(ILOutputService.BlockAwareSequenceEqual(lines1, lines2));
+ }
+
+ [Fact]
+ [Trait("Category", "Unit")]
+ public void BlockAwareSequenceEqual_MethodBodiesMovedBetweenMultilineHeaderClasses_ReturnsFalse()
+ {
+ var lines1 = BuildMultilineHeaderClassIl("ClassA", "ldc.i4.0");
+ lines1.AddRange(BuildMultilineHeaderClassIl("ClassB", "ldc.i4.1"));
+ var lines2 = BuildMultilineHeaderClassIl("ClassA", "ldc.i4.1");
+ lines2.AddRange(BuildMultilineHeaderClassIl("ClassB", "ldc.i4.0"));
+
+ Assert.False(ILOutputService.BlockAwareSequenceEqual(lines1, lines2));
+ }
+
+ [Fact]
+ [Trait("Category", "Unit")]
+ public void BlockAwareSequenceEqual_MethodsReorderedWithinMultilineHeaderClass_ReturnsTrue()
+ {
+ var lines1 = BuildMultilineHeaderClassIl(
+ "ClassA",
+ ("Foo", "ldc.i4.0"),
+ ("Bar", "ldc.i4.1"));
+ var lines2 = BuildMultilineHeaderClassIl(
+ "ClassA",
+ ("Bar", "ldc.i4.1"),
+ ("Foo", "ldc.i4.0"));
+
+ Assert.True(ILOutputService.BlockAwareSequenceEqual(lines1, lines2));
+ }
+
+ [Fact]
+ [Trait("Category", "Unit")]
+ public void BlockAwareSequenceEqual_MethodBodiesMovedBetweenNestedMultilineHeaderClasses_ReturnsFalse()
+ {
+ var lines1 = BuildOuterWithNestedMultilineHeaderClasses(
+ ("NestedA", "ldc.i4.0"),
+ ("NestedB", "ldc.i4.1"));
+ var lines2 = BuildOuterWithNestedMultilineHeaderClasses(
+ ("NestedA", "ldc.i4.1"),
+ ("NestedB", "ldc.i4.0"));
+
+ Assert.False(ILOutputService.BlockAwareSequenceEqual(lines1, lines2));
+ }
+
+ [Fact]
+ [Trait("Category", "Unit")]
+ public void BlockAwareSequenceEqual_InterfaceMethodsReordered_ReturnsFalse()
+ {
+ var lines1 = BuildOrderSensitiveClassIl(
+ ".class public interface abstract auto ansi IComContract",
+ "First",
+ "Second");
+ var lines2 = BuildOrderSensitiveClassIl(
+ ".class public interface abstract auto ansi IComContract",
+ "Second",
+ "First");
+
+ Assert.False(ILOutputService.BlockAwareSequenceEqual(lines1, lines2));
+ }
+
+ [Fact]
+ [Trait("Category", "Unit")]
+ public void BlockAwareSequenceEqual_ImportedTypeMethodsReordered_ReturnsFalse()
+ {
+ var lines1 = BuildOrderSensitiveClassIl(
+ ".class public import auto ansi ImportedType",
+ "First",
+ "Second");
+ var lines2 = BuildOrderSensitiveClassIl(
+ ".class public import auto ansi ImportedType",
+ "Second",
+ "First");
+
+ Assert.False(ILOutputService.BlockAwareSequenceEqual(lines1, lines2));
+ }
+
+ [Fact]
+ [Trait("Category", "Unit")]
+ public void BlockAwareSequenceEqual_ClassNamedInterfaceMethodsReordered_ReturnsTrue()
+ {
+ var lines1 = BuildOrderSensitiveClassIl(
+ ".class public abstract auto ansi 'interface'",
+ "First",
+ "Second");
+ var lines2 = BuildOrderSensitiveClassIl(
+ ".class public abstract auto ansi 'interface'",
+ "Second",
+ "First");
+
+ Assert.True(ILOutputService.BlockAwareSequenceEqual(lines1, lines2));
+ }
+
+ [Fact]
+ [Trait("Category", "Unit")]
+ public void BlockAwareSequenceEqual_OrdinaryNestedClassMethodsInsideInterfaceReordered_ReturnsTrue()
+ {
+ var lines1 = BuildInterfaceWithNestedClass(
+ ("Foo", "ldc.i4.0"),
+ ("Bar", "ldc.i4.1"));
+ var lines2 = BuildInterfaceWithNestedClass(
+ ("Bar", "ldc.i4.1"),
+ ("Foo", "ldc.i4.0"));
+
+ Assert.True(ILOutputService.BlockAwareSequenceEqual(lines1, lines2));
+ }
+
+ private static List BuildClassWithMultilineMarshalHeaders(
+ params (string MethodName, string MarshalBlob)[] methods)
+ {
+ var lines = new List
+ {
+ ".class public auto ansi MarshalClass",
+ "{"
+ };
+
+ foreach (var method in methods)
+ {
+ lines.Add(" .method public hidebysig");
+ lines.Add(" instance void");
+ lines.Add(" marshal({");
+ lines.Add($" {method.MarshalBlob}");
+ lines.Add(" })");
+ lines.Add($" {method.MethodName}() cil managed");
+ lines.Add(" {");
+ lines.Add(" ret");
+ lines.Add(" }");
+ }
+
+ lines.Add("}");
+ return lines;
+ }
+
+ private static List BuildMultilineHeaderClassIl(
+ string className,
+ string bodyInstruction)
+ {
+ return BuildMultilineHeaderClassIl(className, ("Foo", bodyInstruction));
+ }
+
+ private static List BuildMultilineHeaderClassIl(
+ 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(" }");
+ }
+
+ lines.Add("}");
+ return lines;
+ }
+
+ private static List BuildOuterWithNestedMultilineHeaderClasses(
+ params (string ClassName, string BodyInstruction)[] classes)
+ {
+ var lines = new List
+ {
+ ".class public Outer",
+ "{"
+ };
+
+ foreach (var nestedClass in classes)
+ {
+ lines.Add(" .class nested public");
+ lines.Add($" {nestedClass.ClassName}");
+ lines.Add(" {");
+ lines.Add(" .method public void Foo() cil managed");
+ lines.Add(" {");
+ lines.Add($" {nestedClass.BodyInstruction}");
+ lines.Add(" ret");
+ lines.Add(" }");
+ lines.Add(" }");
+ }
+
+ lines.Add("}");
+ return lines;
+ }
+
+ private static List BuildOrderSensitiveClassIl(
+ string classDeclaration,
+ params string[] methodNames)
+ {
+ var lines = new List
+ {
+ classDeclaration,
+ "{"
+ };
+
+ foreach (string methodName in methodNames)
+ {
+ lines.Add($" .method public hidebysig newslot abstract virtual instance void {methodName}() cil managed");
+ lines.Add(" {");
+ lines.Add(" }");
+ }
+
+ lines.Add("}");
+ return lines;
+ }
+
+ private static List BuildInterfaceWithNestedClass(
+ params (string MethodName, string BodyInstruction)[] methods)
+ {
+ var lines = new List
+ {
+ ".class public interface abstract auto ansi IOuter",
+ "{",
+ " .class nested public auto ansi Nested",
+ " {"
+ };
+
+ 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(" }");
+ }
+
+ lines.Add(" }");
+ lines.Add("}");
+ return lines;
+ }
+ }
+}
diff --git a/FolderDiffIL4DotNet.Tests/Services/ILOutputServiceTests.cs b/FolderDiffIL4DotNet.Tests/Services/ILOutputServiceTests.cs
index c7c6ac71..e8b3db53 100644
--- a/FolderDiffIL4DotNet.Tests/Services/ILOutputServiceTests.cs
+++ b/FolderDiffIL4DotNet.Tests/Services/ILOutputServiceTests.cs
@@ -787,6 +787,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()
@@ -1247,5 +1310,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.BlockComparison.cs b/Services/ILOutputService.BlockComparison.cs
new file mode 100644
index 00000000..9b85682d
--- /dev/null
+++ b/Services/ILOutputService.BlockComparison.cs
@@ -0,0 +1,104 @@
+using System;
+using System.Collections.Generic;
+using System.Security.Cryptography;
+using System.Text;
+using FolderDiffIL4DotNet.Core.IL;
+
+namespace FolderDiffIL4DotNet.Services
+{
+ ///
+ /// Hierarchy-aware block comparison helpers for .
+ /// の階層対応ブロック比較補助です。
+ ///
+ public sealed partial class ILOutputService
+ {
+ ///
+ /// Compares two filtered IL line lists using signature-aware, block-based (order-independent) comparison.
+ /// Parses IL into hierarchy-aware blocks via , then compares multisets
+ /// of (fixed-size container path key, signature, hash) tuples. Container keys include complete class headers,
+ /// including multiline declarations. This handles compiler-induced member reordering within ordinary classes
+ /// while preserving declaration order for interface and imported-type members, where order can affect ABI.
+ /// Content changes, body swaps, and moves between classes remain differences.
+ /// フィルタ済み IL 行リストをシグネチャ対応のブロック単位(順序非依存)で比較します。
+ /// で IL を論理ブロック(メソッド、クラス等)に分割し、
+ /// class/member階層を保持したブロックへ解析し、(固定長container path key, シグネチャ, ハッシュ) tupleの
+ /// マルチセットとして比較します。container keyには複数行宣言を含む完全なclass headerを使用します。
+ /// 通常class内のmember並び替えを許容しつつ、ABIへ影響し得るinterface/import typeのmember宣言順を
+ /// 保持します。本体変更、method間の本体入れ替え、class間移動は引き続き正しく検知します。
+ ///
+ internal static bool BlockAwareSequenceEqual(IReadOnlyList filteredLines1, IReadOnlyList filteredLines2)
+ {
+ var blocks1 = ILBlockParser.ParseComparableBlocks(filteredLines1);
+ var blocks2 = ILBlockParser.ParseComparableBlocks(filteredLines2);
+ if (blocks1.Count != blocks2.Count)
+ {
+ return false;
+ }
+
+ var hashBag1 = BuildBlockHashBag(blocks1);
+ var hashBag2 = BuildBlockHashBag(blocks2);
+ if (hashBag1.Count != hashBag2.Count)
+ {
+ return false;
+ }
+
+ foreach (var kvp in hashBag1)
+ {
+ if (!hashBag2.TryGetValue(kvp.Key, out int count2) || count2 != kvp.Value)
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ ///
+ /// Builds a multiset ((fixed-size container key, 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 ブロックのリストからマルチセット((固定長container key, シグネチャ, ハッシュ) → 出現回数)を構築します。
+ /// 各ブロックのシグネチャは で抽出し、
+ /// ブロックの同一性(シグネチャ)と内容(ハッシュ)の両方で照合します。
+ ///
+ private static Dictionary<(string Container, string Signature, string Hash), int> BuildBlockHashBag(
+ IReadOnlyList blocks)
+ {
+ var bag = new Dictionary<(string Container, string Signature, string Hash), int>();
+ foreach (var block in blocks)
+ {
+ string signature = ILBlockParser.ExtractBlockSignature(block.Lines).Trim();
+ string hash = ComputeBlockHash(block.Lines);
+ var key = (block.ContainerComparisonKey, signature, hash);
+ bag.TryGetValue(key, out int count);
+ bag[key] = count + 1;
+ }
+
+ return bag;
+ }
+
+ ///
+ /// Computes a SHA256 hash of an IL block's content (all lines joined with newline).
+ /// IL ブロックの内容(全行を改行で結合)の SHA256 ハッシュを計算します。
+ ///
+ private static string ComputeBlockHash(IReadOnlyList blockLines)
+ {
+ using var sha256 = SHA256.Create();
+ var sb = new StringBuilder();
+ for (int i = 0; i < blockLines.Count; i++)
+ {
+ if (i > 0)
+ {
+ sb.Append('\n');
+ }
+
+ // Trim leading/trailing whitespace to absorb indentation variations
+ // 先頭・末尾空白をトリムしてインデント差異を吸収
+ sb.Append(blockLines[i].Trim());
+ }
+
+ byte[] hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(sb.ToString()));
+ return BitConverter.ToString(hashBytes).Replace("-", string.Empty);
+ }
+ }
+}
diff --git a/Services/ILOutputService.Comparison.cs b/Services/ILOutputService.Comparison.cs
index b185600e..f4bec711 100644
--- a/Services/ILOutputService.Comparison.cs
+++ b/Services/ILOutputService.Comparison.cs
@@ -3,11 +3,9 @@
using System.Globalization;
using System.IO;
using System.Linq;
-using System.Security.Cryptography;
using System.Text;
using FolderDiffIL4DotNet.Common;
using FolderDiffIL4DotNet.Core.Diagnostics;
-using FolderDiffIL4DotNet.Core.IL;
using FolderDiffIL4DotNet.Models;
namespace FolderDiffIL4DotNet.Services
@@ -869,92 +867,6 @@ private static string NormalizeSuffixAfterPrefix(string line, string prefix, str
return string.Concat(line.AsSpan(0, prefixIndex + prefix.Length), replacement);
}
- ///
- /// 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.
- /// フィルタ済み IL 行リストをシグネチャ対応のブロック単位(順序非依存)で比較します。
- /// で IL を論理ブロック(メソッド、クラス等)に分割し、
- /// 各ブロックのシグネチャ(ディレクティブ行)とコンテンツハッシュを抽出してから
- /// (シグネチャ, ハッシュ) ペアのマルチセットとして比較します。コンパイラによる並び替えを
- /// 許容しつつ、異なるシグネチャのブロック間でのコンテンツ入れ替わりを正しく検知します。
- ///
- internal static bool BlockAwareSequenceEqual(IReadOnlyList filteredLines1, IReadOnlyList filteredLines2)
- {
- var blocks1 = ILBlockParser.ParseBlocks(filteredLines1);
- var blocks2 = ILBlockParser.ParseBlocks(filteredLines2);
- if (blocks1.Count != blocks2.Count)
- {
- return false;
- }
-
- var hashBag1 = BuildBlockHashBag(blocks1);
- var hashBag2 = BuildBlockHashBag(blocks2);
- if (hashBag1.Count != hashBag2.Count)
- {
- return false;
- }
-
- foreach (var kvp in hashBag1)
- {
- if (!hashBag2.TryGetValue(kvp.Key, out int count2) || count2 != kvp.Value)
- {
- return false;
- }
- }
-
- return true;
- }
-
- ///
- /// Builds a multiset ((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 ブロックのリストからマルチセット((シグネチャ, ハッシュ) → 出現回数)を構築します。
- /// 各ブロックのシグネチャは で抽出し、
- /// ブロックの同一性(シグネチャ)と内容(ハッシュ)の両方で照合します。
- ///
- private static Dictionary<(string Signature, string Hash), int> BuildBlockHashBag(List> blocks)
- {
- var bag = new Dictionary<(string Signature, string Hash), int>();
- foreach (var block in blocks)
- {
- string signature = ILBlockParser.ExtractBlockSignature(block);
- string hash = ComputeBlockHash(block);
- var key = (signature, hash);
- bag.TryGetValue(key, out int count);
- bag[key] = count + 1;
- }
-
- return bag;
- }
-
- ///
- /// Computes a SHA256 hash of an IL block's content (all lines joined with newline).
- /// IL ブロックの内容(全行を改行で結合)の SHA256 ハッシュを計算します。
- ///
- private static string ComputeBlockHash(List blockLines)
- {
- using var sha256 = SHA256.Create();
- var sb = new StringBuilder();
- for (int i = 0; i < blockLines.Count; i++)
- {
- if (i > 0)
- {
- sb.Append('\n');
- }
-
- // Trim leading/trailing whitespace to absorb indentation variations
- // 先頭・末尾空白をトリムしてインデント差異を吸収
- sb.Append(blockLines[i].Trim());
- }
-
- byte[] hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(sb.ToString()));
- return BitConverter.ToString(hashBytes).Replace("-", string.Empty);
- }
-
///
/// Validates configured IL line-ignore strings and returns warning messages for strings
/// shorter than characters.
diff --git a/USER_GUIDE.md b/USER_GUIDE.md
index 99b67c3e..7dfeb630 100644
--- a/USER_GUIDE.md
+++ b/USER_GUIDE.md
@@ -493,6 +493,7 @@ 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.
+- IL block comparison treats direct method, property, and event order as interchangeable only in ordinary classes. Direct members of `interface` or `import` IL types remain order-sensitive because their declaration order can define interop ABI layout, including COM vtable slots.
- If [`ShouldIgnoreILLinesContainingConfiguredStrings`](#config-en-shouldignoreillinescontainingconfiguredstrings) is `true`, lines containing any configured ignore string are skipped during IL comparison. Matching is applied to every IL line without interpreting its instruction context. The `ldstr` instruction, which loads a program string literal onto the evaluation stack, is not an exception: a configured match inside its literal excludes the entire `ldstr` line.
- If [`ShouldILNormalizeContainingConfiguredStrings`](#config-en-shouldilnormalizecontainingconfiguredstrings) is `true`, matching portions configured in [`ILNormalizeContainingStrings`](#config-en-ilnormalizecontainingstrings) are replaced with a comparison-local collision-free marker before comparison. The marker is normally ``; if that text already occurs in either raw IL input, nildiff adds the first available numeric suffix such as `-1`. Replacement is applied to every IL line without interpreting its instruction context. The `ldstr` instruction, which loads a program string literal onto the evaluation stack, is not an exception: only the configured substring inside its literal is replaced, and the rest of the line remains comparable. MVID, RVA, code-size comments, and the WinForms `TypeLibraryTimeStampAttribute` value are normalized with rule-specific placeholders in every mode. For ilspycmd's multiline `TypeLibraryTimeStampAttribute` form, nildiff preserves the attribute header prefix and replaces the complete byte blob through its closing `)` with one stable marker. Every built-in pattern is tested against every IL text regardless of the disassembler used; the report's **Observed Output From** value records where that syntax was verified, not a condition that limits rule application.
- `ShouldIgnoreMVID` has been removed. If that key or the former `FOLDERDIFF_SHOULDIGNOREMVID` environment variable is specified, nildiff stops with a migration error instead of silently accepting it; remove the obsolete input. MVID values are always normalized.
@@ -1402,6 +1403,7 @@ flowchart TD
重要な点:
- `Added` / `Removed` / `Unchanged` / `Modified` は、ファイル名だけでなく相対パスを基準に決まります。
+- IL block比較で直接method/property/eventの順序を入れ替え可能として扱うのは通常classだけです。`interface` または `import` IL typeの直接memberは、その宣言順がCOM vtable slotを含むinterop ABI配置を定義し得るため、順序を比較対象として保持します。
- [`ShouldIgnoreILLinesContainingConfiguredStrings`](#config-ja-shouldignoreillinescontainingconfiguredstrings) が `true` の場合は、設定した文字列を含む行を IL 比較から除外します。一致判定は命令の文脈を解釈せず、すべての IL 行へ適用します。プログラムの文字列リテラルを評価スタックへ読み込む命令である `ldstr` も例外ではなく、そのリテラル内で設定値が一致した場合は `ldstr` 行全体を除外します。
- [`ShouldILNormalizeContainingConfiguredStrings`](#config-ja-shouldilnormalizecontainingconfiguredstrings) が `true` の場合は、[`ILNormalizeContainingStrings`](#config-ja-ilnormalizecontainingstrings) の一致部分を比較ペア内で衝突しないマーカーへ置換してから比較します。通常は `` を使い、この文字列が old/new いずれかの raw IL に既にある場合は `-1` など未使用の最初の数値 suffix を付けます。置換は命令の文脈を解釈せず、すべての IL 行へ適用します。プログラムの文字列リテラルを評価スタックへ読み込む命令である `ldstr` も例外ではなく、そのリテラル内では設定文字列に一致した部分だけを置換し、行の残りを比較対象に保持します。MVID、RVA、code-size コメント、WinForms の `TypeLibraryTimeStampAttribute` 値は全モードで規則別プレースホルダーへ正規化します。ilspycmd の複数行 `TypeLibraryTimeStampAttribute` 形式では、attribute header の接頭辞を保持し、閉じ `)` までのbyte blob全体を1個の安定したマーカーへ置換します。組み込みパターンは使用した逆アセンブラに関係なく、すべての IL text に対して全件評価されます。レポートの **Observed Output From** はその構文を確認した由来を示すだけで、規則の適用条件ではありません。
- `ShouldIgnoreMVID` は廃止しました。このkeyまたは旧環境変数 `FOLDERDIFF_SHOULDIGNOREMVID` を指定すると、黙って受理せず移行エラーで停止します。廃止済みの指定を削除してください。MVID値は常に正規化します。
diff --git a/doc/DEVELOPER_GUIDE.md b/doc/DEVELOPER_GUIDE.md
index a54bd22c..3a89b0e2 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, configured IL substring safety validation | Enforces same disassembler identity; falls back to block-level multiset comparison when line order differs; `ValidateILIgnoreContainingStrings` and `ValidateILNormalizeContainingStrings` validate configured patterns |
-| [`Services/ILOutput/ILBlockParser.cs`](../Services/ILOutput/ILBlockParser.cs) | Parses IL disassembly output into logical blocks (methods, classes, properties) | Used by `ILOutputService.BlockAwareSequenceEqual` for order-independent comparison |
+| [`Services/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 ordinary-class blocks by complete parent class header, member signature, and content hash while keeping interface/import direct-member order significant; `ValidateILIgnoreContainingStrings` and `ValidateILNormalizeContainingStrings` validate configured patterns |
+| [`FolderDiffIL4DotNet.Core/IL/ILBlockParser.cs`](../FolderDiffIL4DotNet.Core/IL/ILBlockParser.cs) | Parses IL disassembly output into logical blocks and comparison-specific class/member blocks | `ParseComparableBlocks` preserves complete class-header hierarchy while using one forward pass and an explicit stack, so deeply nested classes avoid recursive stack growth and repeated subtree copies without hiding body changes, cross-class moves, or interface/import direct-member order changes |
| [`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のblockを完全な親class header、memberシグネチャ、内容ハッシュで照合し、interface/importの直接member順は比較対象として保持;`ValidateILIgnoreContainingStrings` と `ValidateILNormalizeContainingStrings` が設定パターンを検証 |
+| [`FolderDiffIL4DotNet.Core/IL/ILBlockParser.cs`](../FolderDiffIL4DotNet.Core/IL/ILBlockParser.cs) | IL 逆アセンブリ出力を論理ブロックと比較専用のclass/memberブロックへ分割 | `ParseComparableBlocks` は完全なclass header階層を保持しつつ、単一の前方走査と明示stackを使います。深いnested classでも再帰stack増加とsubtreeの反復copyを避け、本体変更、class間移動、interface/importの直接member順変更を隠さずに通常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 b075429a..825995a7 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 ordinary parent class, while interface/import direct-member reordering, body edits, body swaps between signatures, moves between classes, multiline class-header identity collisions, and multiline `marshal({ ... })` header boundaries 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並び替えだけを許容し、interface/importの直接member順変更、method本体の変更、異なるシグネチャ間での本体入れ替え、class間移動、複数行class headerのidentity衝突、複数行 `marshal({ ... })` header境界は差分のままであることを検証します。
- [`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 失敗・大量ファイル入力を再現できます。