diff --git a/CHANGELOG.md b/CHANGELOG.md index b8f800de..68983d85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### [Unreleased] +#### Added + +- Added **Assembly Semantic Changes** — member-level change detection for `ILMismatch` assemblies using `System.Reflection.Metadata`. For each modified .NET assembly, the report now shows type/method/property/field additions, removals, and method body changes. Controlled by the new `ShouldIncludeAssemblySemanticChangesInReport` config setting (default: `true`). + - **Report placement**: appears between **Summary** and **IL Cache Stats** in the Markdown report; shown as an expandable inline row above the IL diff in the HTML report. + - **Table columns** (12 in HTML including checkbox; 11 in Markdown): `✓` (checkbox, HTML only), `Class` (fully qualified type name), `BaseType` (base class + interfaces), `Change` (`Added`/`Removed`/`Modified`), `Kind` (`Class`/`Record`/`Struct`/`Interface`/`Enum`/`Constructor`/`StaticConstructor`/`Method`/`Property`/`Field`), `Access` (`public`/`internal`/`protected`/`private`), `Modifiers` (`static`/`abstract`/`virtual`/`override`/`sealed`/`readonly`/`const`/`static literal`/`static readonly`), `Type` (declared type for Field/Property only), `Name`, `ReturnType`, `Parameters` (displayed without parentheses), `Body` (`Changed` when method body or field initializer IL has changed; otherwise empty). + - **Summary count table**: a second table grouped by Class showing `Added`/`Removed`/`Modified` counts. Consecutive rows for the same class merge the Class cell. + - **Record detection**: records are identified heuristically by the presence of an `EqualityContract` property. + - **HTML styling**: table header background `#98989d` (light grey), data cell (`td`) background `#fff` (white) for contrast against the `diff-row` grey, per-row checkbox with auto-save, resizable columns via CSS custom properties and drag handles. + - **Markdown styling**: multi-word Modifiers (e.g. `static literal`) use non-breaking spaces to prevent wrapping. + - **New files**: [`AssemblyMethodAnalyzer`](Services/AssemblyMethodAnalyzer.cs), [`AssemblySemanticChangesSummary`](Models/AssemblySemanticChangesSummary.cs) (with computed `AddedCount`/`RemovedCount`/`ModifiedCount` properties). + - **Tests**: [`AssemblyMethodAnalyzerTests`](FolderDiffIL4DotNet.Tests/Services/AssemblyMethodAnalyzerTests.cs), [`AssemblySemanticChangesSummaryTests`](FolderDiffIL4DotNet.Tests/Models/AssemblySemanticChangesSummaryTests.cs), and new assertions in [`HtmlReportGenerateServiceTests`](FolderDiffIL4DotNet.Tests/Services/HtmlReportGenerateServiceTests.cs) (`TableHeaderUsesLighterGray`, `ThScColCbCssRuleExists`, `TdHasWhiteBackground`). + - **Docs**: added bilingual **Assembly Semantic Changes** section to [README.md](README.md), [`AssemblyMethodAnalyzerTests`](FolderDiffIL4DotNet.Tests/Services/AssemblyMethodAnalyzerTests.cs) and [`AssemblySemanticChangesSummaryTests`](FolderDiffIL4DotNet.Tests/Models/AssemblySemanticChangesSummaryTests.cs) to [TESTING_GUIDE.md](doc/TESTING_GUIDE.md), updated [`doc/samples/diff_report.html`](doc/samples/diff_report.html) and [`doc/samples/diff_report.md`](doc/samples/diff_report.md). + ### [1.4.1] - 2026-03-20 #### Added @@ -365,6 +378,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### [Unreleased] +#### 追加 + +- **Assembly Semantic Changes** を追加 — `System.Reflection.Metadata` を使用した `ILMismatch` アセンブリのメンバーレベル変更検出。変更のあった各 .NET アセンブリについて、型・メソッド・プロパティ・フィールドの増減およびメソッドボディの変更をレポートに出力します。新しい設定項目 `ShouldIncludeAssemblySemanticChangesInReport`(既定: `true`)で制御可能。 + - **レポート配置**: Markdown レポートでは **Summary** と **IL Cache Stats** の間に表示。HTML レポートでは IL diff の上に展開可能なインライン行として表示。 + - **テーブル列**(HTML: チェックボックス含む 12 列、Markdown: 11 列): `✓`(チェックボックス、HTML のみ)、`Class`(完全修飾型名)、`BaseType`(基底クラス+インターフェース)、`Change`(`Added`/`Removed`/`Modified`)、`Kind`(`Class`/`Record`/`Struct`/`Interface`/`Enum`/`Constructor`/`StaticConstructor`/`Method`/`Property`/`Field`)、`Access`(`public`/`internal`/`protected`/`private`)、`Modifiers`(`static`/`abstract`/`virtual`/`override`/`sealed`/`readonly`/`const`/`static literal`/`static readonly`)、`Type`(Field/Property の宣言型のみ)、`Name`、`ReturnType`、`Parameters`(括弧なしで表示)、`Body`(メソッドボディまたはフィールド初期化子の IL 変更時に `Changed`、それ以外は空欄)。 + - **集計テーブル**: Class 別に `Added`/`Removed`/`Modified` の件数を表示する第二テーブル。同一クラスの連続行は Class セルを結合。 + - **Record 検出**: `EqualityContract` プロパティの有無で Record 型をヒューリスティックに判定。 + - **HTML スタイル**: テーブルヘッダ背景 `#98989d`(ライトグレー)、データセル(`td`)背景 `#fff`(白)で `diff-row` グレーとのコントラストを確保、行単位チェックボックス(auto-save 対応)、CSS カスタムプロパティとドラッグハンドルによるリサイズ可能な列。 + - **Markdown スタイル**: 複数語の Modifiers(例: `static literal`)にノーブレークスペースを適用し折り返しを防止。 + - **新規ファイル**: [`AssemblyMethodAnalyzer`](Services/AssemblyMethodAnalyzer.cs)、[`AssemblySemanticChangesSummary`](Models/AssemblySemanticChangesSummary.cs)(算出プロパティ `AddedCount`/`RemovedCount`/`ModifiedCount`)。 + - **テスト**: [`AssemblyMethodAnalyzerTests`](FolderDiffIL4DotNet.Tests/Services/AssemblyMethodAnalyzerTests.cs)、[`AssemblySemanticChangesSummaryTests`](FolderDiffIL4DotNet.Tests/Models/AssemblySemanticChangesSummaryTests.cs)、[`HtmlReportGenerateServiceTests`](FolderDiffIL4DotNet.Tests/Services/HtmlReportGenerateServiceTests.cs) に新規アサーション(`TableHeaderUsesLighterGray`、`ThScColCbCssRuleExists`、`TdHasWhiteBackground`)。 + - **ドキュメント**: [README.md](README.md) にバイリンガルの **Assembly Semantic Changes** セクションを追加、[TESTING_GUIDE.md](doc/TESTING_GUIDE.md) に [`AssemblyMethodAnalyzerTests`](FolderDiffIL4DotNet.Tests/Services/AssemblyMethodAnalyzerTests.cs)・[`AssemblySemanticChangesSummaryTests`](FolderDiffIL4DotNet.Tests/Models/AssemblySemanticChangesSummaryTests.cs) を追加、[`doc/samples/diff_report.html`](doc/samples/diff_report.html)・[`doc/samples/diff_report.md`](doc/samples/diff_report.md) を同期。 + ### [1.4.1] - 2026-03-20 #### 追加 diff --git a/FolderDiffIL4DotNet.Tests/Models/AssemblySemanticChangesSummaryTests.cs b/FolderDiffIL4DotNet.Tests/Models/AssemblySemanticChangesSummaryTests.cs new file mode 100644 index 00000000..b1529869 --- /dev/null +++ b/FolderDiffIL4DotNet.Tests/Models/AssemblySemanticChangesSummaryTests.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using FolderDiffIL4DotNet.Models; +using Xunit; + +namespace FolderDiffIL4DotNet.Tests.Models +{ + public sealed class AssemblySemanticChangesSummaryTests + { + [Fact] + public void HasChanges_DefaultInstance_ReturnsFalse() + { + var summary = new AssemblySemanticChangesSummary(); + Assert.False(summary.HasChanges); + } + + [Fact] + public void HasChanges_WithEntries_ReturnsTrue() + { + var summary = new AssemblySemanticChangesSummary + { + Entries = new List + { + new("Added", "MyApp.Service", "", "public", "", "Method", "DoWork", "", "void", "int count", ""), + }, + }; + Assert.True(summary.HasChanges); + } + + [Fact] + public void HasChanges_EmptyEntries_ReturnsFalse() + { + var summary = new AssemblySemanticChangesSummary + { + Entries = new List(), + }; + Assert.False(summary.HasChanges); + } + + [Fact] + public void Entries_ContainStructuredData() + { + var entry = new MemberChangeEntry("Added", "MyApp.Service", "SomeBase", "public", "static", "Method", "GetName", "", "string", "string id", ""); + Assert.Equal("Added", entry.Change); + Assert.Equal("MyApp.Service", entry.TypeName); + Assert.Equal("SomeBase", entry.BaseType); + Assert.Equal("public", entry.Access); + Assert.Equal("static", entry.Modifiers); + Assert.Equal("Method", entry.MemberKind); + Assert.Equal("GetName", entry.MemberName); + Assert.Equal("", entry.MemberType); + Assert.Equal("string", entry.ReturnType); + Assert.Equal("string id", entry.Parameters); + Assert.Equal("", entry.Body); + } + } +} diff --git a/FolderDiffIL4DotNet.Tests/Services/AssemblyMethodAnalyzerTests.cs b/FolderDiffIL4DotNet.Tests/Services/AssemblyMethodAnalyzerTests.cs new file mode 100644 index 00000000..3375b15b --- /dev/null +++ b/FolderDiffIL4DotNet.Tests/Services/AssemblyMethodAnalyzerTests.cs @@ -0,0 +1,79 @@ +using System.Linq; +using FolderDiffIL4DotNet.Services; +using Xunit; + +namespace FolderDiffIL4DotNet.Tests.Services +{ + public sealed class AssemblyMethodAnalyzerTests + { + [Fact] + public void Analyze_SameAssembly_NoChanges() + { + // Compare a real assembly to itself — should report no changes + // 実アセンブリを自分自身と比較 — 変更なしが期待される + var assemblyPath = typeof(AssemblyMethodAnalyzerTests).Assembly.Location; + var result = AssemblyMethodAnalyzer.Analyze(assemblyPath, assemblyPath); + + Assert.NotNull(result); + Assert.False(result.HasChanges); + Assert.Empty(result.Entries); + Assert.Equal(0, result.AddedCount); + Assert.Equal(0, result.RemovedCount); + Assert.Equal(0, result.ModifiedCount); + } + + [Fact] + public void Analyze_NonExistentFile_ReturnsNull() + { + // Attempting to analyse a missing file should gracefully return null + // 存在しないファイルの解析は null を返すべき + var result = AssemblyMethodAnalyzer.Analyze("/nonexistent/old.dll", "/nonexistent/new.dll"); + Assert.Null(result); + } + + [Fact] + public void Analyze_InvalidFile_ReturnsNull() + { + // Attempting to analyse a non-PE file should gracefully return null + // PE でないファイルの解析は null を返すべき + var textFile = typeof(AssemblyMethodAnalyzerTests).Assembly.Location + ".runtimeconfig.json"; + if (!System.IO.File.Exists(textFile)) return; // skip if runtime config not available + var result = AssemblyMethodAnalyzer.Analyze(textFile, textFile); + Assert.Null(result); + } + + [Fact] + public void Analyze_DifferentAssemblies_DetectsChanges() + { + // Compare test assembly to main assembly — should detect differences + // テストアセンブリとメインアセンブリを比較 — 差異が検出されるべき + var testAssembly = typeof(AssemblyMethodAnalyzerTests).Assembly.Location; + var mainAssembly = typeof(FolderDiffIL4DotNet.Models.ConfigSettings).Assembly.Location; + + var result = AssemblyMethodAnalyzer.Analyze(testAssembly, mainAssembly); + + Assert.NotNull(result); + Assert.True(result.HasChanges); + Assert.True(result.Entries.Count > 0); + } + + [Fact] + public void Analyze_DifferentAssemblies_EntriesHaveStructuredData() + { + // Entries should contain structured MemberChangeEntry data + // エントリには構造化された MemberChangeEntry データが含まれるべき + var testAssembly = typeof(AssemblyMethodAnalyzerTests).Assembly.Location; + var mainAssembly = typeof(FolderDiffIL4DotNet.Models.ConfigSettings).Assembly.Location; + + var result = AssemblyMethodAnalyzer.Analyze(testAssembly, mainAssembly); + + Assert.NotNull(result); + var firstEntry = result.Entries.First(); + Assert.False(string.IsNullOrEmpty(firstEntry.Change)); + Assert.False(string.IsNullOrEmpty(firstEntry.TypeName)); + Assert.False(string.IsNullOrEmpty(firstEntry.MemberKind)); + Assert.Contains(firstEntry.Change, new[] { "Added", "Removed", "Modified" }); + Assert.Contains(firstEntry.MemberKind, new[] { "Class", "Record", "Struct", "Interface", "Enum", "Constructor", "StaticConstructor", "Method", "Property", "Field" }); + } + } +} diff --git a/FolderDiffIL4DotNet.Tests/Services/HtmlReportGenerateServiceTests.cs b/FolderDiffIL4DotNet.Tests/Services/HtmlReportGenerateServiceTests.cs index 7c804234..70546ea3 100644 --- a/FolderDiffIL4DotNet.Tests/Services/HtmlReportGenerateServiceTests.cs +++ b/FolderDiffIL4DotNet.Tests/Services/HtmlReportGenerateServiceTests.cs @@ -716,6 +716,229 @@ public void GenerateDiffReportHtml_LazyRender_JsSetupFunctionPresent() Assert.Contains("data-diff-html", html); // JS references the attribute name } + // ── Assembly Semantic Changes / アセンブリ意味変更 ───────────────────── + + [Fact] + public void GenerateDiffReportHtml_AssemblySemanticChanges_ShowsInlineAboveILDiff() + { + var (oldDir, newDir, reportDir) = MakeDirs("semantic-changes"); + + _resultLists.AddModifiedFileRelativePath("lib.dll"); + _resultLists.RecordDiffDetail("lib.dll", FileDiffResultLists.DiffDetailResult.ILMismatch, "dotnet-ildasm (version: 0.12.0)"); + + _resultLists.FileRelativePathToAssemblySemanticChanges["lib.dll"] = new AssemblySemanticChangesSummary + { + Entries = new List + { + new("Added", "MyApp.Service", "", "public", "", "Method", "NewMethod", "", "void", "string name", ""), + new("Modified", "MyApp.Service", "", "public", "virtual", "Method", "ExistingMethod", "", "bool", "int id", "Changed"), + new("Added", "MyApp.Service", "", "public", "", "Property", "NewProp", "string", "", "", ""), + new("Removed", "MyApp.Service", "", "private", "readonly", "Field", "_oldField", "int", "", "", ""), + }, + }; + + var config = CreateConfig(enableInlineDiff: true); + config.ShouldIncludeAssemblySemanticChangesInReport = true; + _service.GenerateDiffReportHtml(oldDir, newDir, reportDir, + appVersion: "1.0", elapsedTimeString: null, + computerName: "test-host", config); + + var html = File.ReadAllText(Path.Combine(reportDir, HtmlReportGenerateService.DIFF_REPORT_HTML_FILE_NAME)); + Assert.Contains("Show assembly semantic changes", html); + Assert.Contains("semantic_mod_0", html); + Assert.Contains("semantic-changes-table", html); + } + + [Fact] + public void GenerateDiffReportHtml_AssemblySemanticChanges_NotShownWhenDisabled() + { + var (oldDir, newDir, reportDir) = MakeDirs("semantic-changes-off"); + + _resultLists.AddModifiedFileRelativePath("lib.dll"); + _resultLists.RecordDiffDetail("lib.dll", FileDiffResultLists.DiffDetailResult.ILMismatch, "dotnet-ildasm (version: 0.12.0)"); + + _resultLists.FileRelativePathToAssemblySemanticChanges["lib.dll"] = new AssemblySemanticChangesSummary + { + Entries = new List + { + new("Added", "MyApp.Service", "", "public", "", "Method", "NewMethod", "", "void", "string name", ""), + }, + }; + + var config = CreateConfig(enableInlineDiff: true); + config.ShouldIncludeAssemblySemanticChangesInReport = false; + _service.GenerateDiffReportHtml(oldDir, newDir, reportDir, + appVersion: "1.0", elapsedTimeString: null, + computerName: "test-host", config); + + var html = File.ReadAllText(Path.Combine(reportDir, HtmlReportGenerateService.DIFF_REPORT_HTML_FILE_NAME)); + Assert.DoesNotContain("Show assembly semantic changes", html); + } + + [Fact] + public void GenerateDiffReportHtml_AssemblySemanticChanges_LazyRender_EncodesAsBase64() + { + var (oldDir, newDir, reportDir) = MakeDirs("semantic-changes-lazy"); + + _resultLists.AddModifiedFileRelativePath("lib.dll"); + _resultLists.RecordDiffDetail("lib.dll", FileDiffResultLists.DiffDetailResult.ILMismatch, "dotnet-ildasm (version: 0.12.0)"); + + _resultLists.FileRelativePathToAssemblySemanticChanges["lib.dll"] = new AssemblySemanticChangesSummary + { + Entries = new List + { + new("Added", "Foo", "", "public", "", "Method", "Bar", "", "void", "", ""), + }, + }; + + var config = CreateConfig(enableInlineDiff: true, lazyRender: true); + config.ShouldIncludeAssemblySemanticChangesInReport = true; + _service.GenerateDiffReportHtml(oldDir, newDir, reportDir, + appVersion: "1.0", elapsedTimeString: null, + computerName: "test-host", config); + + var html = File.ReadAllText(Path.Combine(reportDir, HtmlReportGenerateService.DIFF_REPORT_HTML_FILE_NAME)); + // Should contain a data-diff-html attribute for the semantic changes row + Assert.Contains("semantic_mod_0", html); + Assert.Contains("Show assembly semantic changes", html); + // Content should NOT be inline (lazy rendered) — table markup is base64-encoded + Assert.DoesNotContain(" + /// Verifies the semantic-changes table header background uses the lighter gray (#98989d). + /// semantic-changes テーブルヘッダ背景に薄い灰色 (#98989d) が使われることを確認する。 + /// + [Fact] + public void GenerateDiffReportHtml_AssemblySemanticChanges_TableHeaderUsesLighterGray() + { + var (oldDir, newDir, reportDir) = MakeDirs("sc-header-color"); + var config = CreateConfig(); + + _service.GenerateDiffReportHtml(oldDir, newDir, reportDir, + appVersion: "1.0", elapsedTimeString: null, + computerName: "test-host", config); + + var html = File.ReadAllText(Path.Combine(reportDir, HtmlReportGenerateService.DIFF_REPORT_HTML_FILE_NAME)); + // Semantic changes table header must use lighter gray (#98989d), not the old dark gray (#6b6b6e) + // semantic-changes テーブルヘッダは旧暗灰色(#6b6b6e)ではなく薄灰色(#98989d)であること + Assert.Contains("background: #98989d", html); + Assert.DoesNotContain("background: #6b6b6e", html); + } + + /// + /// Verifies that the checkbox column header (✓) is present in the semantic-changes detail table. + /// semantic-changes 詳細テーブルにチェック列ヘッダ (✓) が存在することを確認する。 + /// + [Fact] + public void GenerateDiffReportHtml_AssemblySemanticChanges_CheckboxHeaderPresent() + { + var (oldDir, newDir, reportDir) = MakeDirs("sc-cb-header"); + + _resultLists.AddModifiedFileRelativePath("lib.dll"); + _resultLists.RecordDiffDetail("lib.dll", FileDiffResultLists.DiffDetailResult.ILMismatch, "dotnet-ildasm (version: 0.12.0)"); + + _resultLists.FileRelativePathToAssemblySemanticChanges["lib.dll"] = new AssemblySemanticChangesSummary + { + Entries = new List + { + new("Added", "Foo", "", "public", "", "Method", "Bar", "", "void", "", ""), + }, + }; + + var config = CreateConfig(enableInlineDiff: true, lazyRender: false); + config.ShouldIncludeAssemblySemanticChangesInReport = true; + _service.GenerateDiffReportHtml(oldDir, newDir, reportDir, + appVersion: "1.0", elapsedTimeString: null, + computerName: "test-host", config); + + var html = File.ReadAllText(Path.Combine(reportDir, HtmlReportGenerateService.DIFF_REPORT_HTML_FILE_NAME)); + // Detail table must have checkbox header (✓) AND body checkboxes + // 詳細テーブルにチェックヘッダ(✓)とボディのチェックボックスが両方存在すること + Assert.Contains("", html); + Assert.Contains(" + + + + + @@ -450,6 +492,7 @@ For developer-focused details (architecture, exception handling, test setup, CI/ | 見たい内容 | ドキュメント | | --- | --- | | 製品概要、導入、使い方、設定 | [README.md](README.md#readme-ja-usage) | +| アセンブリ セマンティック変更の検出 | [README.md](README.md#readme-ja-assembly-semantic-changes) | | 実行時アーキテクチャ、実行フロー、DI スコープ、実装上の注意点 | [doc/DEVELOPER_GUIDE.md](doc/DEVELOPER_GUIDE.md#guide-ja-map) | | テスト戦略、ローカル実行コマンド、カバレッジ、分離ルール | [doc/TESTING_GUIDE.md](doc/TESTING_GUIDE.md#testing-ja-run-tests) | | XML ドキュメントコメントから生成する API リファレンス | [docfx.json](docfx.json) 経由 [api/index.md](api/index.md) | @@ -625,6 +668,42 @@ flowchart TD - [`ShouldIgnoreILLinesContainingConfiguredStrings`](#config-ja-shouldignoreillinescontainingconfiguredstrings) が `true` の場合は、設定した文字列を含む行も IL 比較から除外します。 - IL 比較そのものに失敗した場合は、弱い比較へ黙って落とさず、その実行全体を停止します。 + +## アセンブリ セマンティック変更 + +アセンブリが `ILMismatch` に分類された場合、[`System.Reflection.Metadata`](https://learn.microsoft.com/dotnet/api/system.reflection.metadata) を使用してメンバーレベルの**セマンティック解析**を追加実行します。結果は Markdown レポートの **Assembly Semantic Changes** セクション、および HTML レポートの展開可能なインライン行に表示されます。 + +### 検出対象 + +| カテゴリ | 検出内容 | +|---------|---------| +| **Type** | 型の追加・削除(ネスト型を含む)、基底型および実装インターフェース情報付き | +| **Method** | メソッドの追加・削除・IL ボディの変更 | +| **Property** | プロパティの追加・削除(get/set アクセサ情報付き) | +| **Field** | フィールドの追加・削除(型と既定値付き) | +| **Access** | `public`, `protected`, `internal`, `private`, `protected internal`, `private protected` | +| **Modifiers** | 型: `sealed`, `abstract`, `static`。メンバー: `static`, `abstract`, `virtual`, `override`, `sealed override`, `const`, `readonly` | + +### レポートテーブル列 + +| 列 | 説明 | 例 | +|----|------|-----| +| Class | 完全修飾型名 | `MyNamespace.MyClass` | +| BaseType | 基底型および実装インターフェース(System.Object 等の自明な基底型は省略) | `MyApp.BaseController, System.IDisposable` | +| Change | `Added`、`Removed`、`Modified` | `Added` | +| Kind | メンバー種別: `Class`, `Record`, `Struct`, `Interface`, `Enum`, `Constructor`, `StaticConstructor`, `Method`, `Property`, `Field` | `Method` | +| Access | アクセス修飾子 | `public` | +| Modifiers | その他の修飾子(型: `sealed`, `abstract`, `static`、メンバー: `static`, `virtual` 等) | `sealed` | +| Type | Field/Property の宣言型(完全修飾 .NET 型名、Method/Constructor/Class/Record の場合は空) | `System.Int32` | +| Name | メンバー名(コンストラクタはクラス名、Class/Record/Struct/Interface/Enum エントリの場合は空) | `DoWork` | +| ReturnType | Method/Constructor の戻り値型(完全修飾 .NET 型名、Field/Property/Class/Record の場合は空) | `System.Void` | +| Parameters | Method/Constructor のパラメータ一覧(完全修飾 .NET 型名、Field/Property/Class/Record の場合は空) | `System.String name, System.Int32 count = 0` | +| Body | メソッドボディまたはフィールド初期化子の IL が変更された場合 `Changed`、それ以外は空 | `Changed` | + +[`ShouldIncludeAssemblySemanticChangesInReport`](#config-ja-shouldincludeassemblysemanticchangesinreport)(既定値: `true`)で制御します。 + +テーブル下に集計テーブル(`Class | Change | Count`)を表示し、クラスと変更種別ごとのカウントをまとめます。同一クラスが連続する場合、Class 列は先頭行のみに表示されます。 + ## 設定([`config.json`](config.json)) 実行ファイルと同じディレクトリに配置します。全項目省略可能で、未指定の項目は [`ConfigSettings`](Models/ConfigSettings.cs) に定義されたコード既定値を使います。既定値のままでよければ、次のように空オブジェクトだけで構いません。 @@ -682,6 +761,11 @@ flowchart TD + + + + + diff --git a/Services/AssemblyMethodAnalyzer.cs b/Services/AssemblyMethodAnalyzer.cs new file mode 100644 index 00000000..0b403391 --- /dev/null +++ b/Services/AssemblyMethodAnalyzer.cs @@ -0,0 +1,809 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.Metadata.Ecma335; +using System.Reflection.PortableExecutable; +using FolderDiffIL4DotNet.Models; + +namespace FolderDiffIL4DotNet.Services +{ + /// + /// Compares two .NET assemblies at the metadata level using + /// to detect type, method, property, and field additions/removals and method body changes. + /// Returns structured records for table-style rendering. + /// を使用して 2 つの .NET アセンブリのメタデータを比較し、 + /// 型・メソッド・プロパティ・フィールドの増減およびメソッドボディの変更を検出します。 + /// 表形式レンダリング向けの構造化 レコードを返します。 + /// + internal static class AssemblyMethodAnalyzer + { + /// + /// Analyses two assembly files and returns a summary of assembly semantic changes. + /// Returns if analysis fails (best-effort). + /// 2 つのアセンブリファイルを解析し、アセンブリセマンティック変更要約を返します。 + /// 解析に失敗した場合は を返します(ベストエフォート)。 + /// + public static AssemblySemanticChangesSummary? Analyze(string oldAssemblyPath, string newAssemblyPath) + { + try + { + var oldSnapshot = ReadAssemblySnapshot(oldAssemblyPath); + var newSnapshot = ReadAssemblySnapshot(newAssemblyPath); + + var entries = new List(); + + // Helper: look up BaseType string for a given type name from the appropriate snapshot + string LookupBaseType(string typeName, AssemblySnapshot preferred, AssemblySnapshot fallback) + { + if (preferred.TypeNames.TryGetValue(typeName, out var info)) return info.BaseType; + if (fallback.TypeNames.TryGetValue(typeName, out info)) return info.BaseType; + return ""; + } + + // Types + foreach (var t in newSnapshot.TypeNames.Keys.Except(oldSnapshot.TypeNames.Keys, StringComparer.Ordinal).OrderBy(t => t, StringComparer.Ordinal)) + { + var info = newSnapshot.TypeNames[t]; + entries.Add(new MemberChangeEntry("Added", t, info.BaseType, info.Access, info.Modifiers, info.Kind, "", "", "", "", "")); + } + foreach (var t in oldSnapshot.TypeNames.Keys.Except(newSnapshot.TypeNames.Keys, StringComparer.Ordinal).OrderBy(t => t, StringComparer.Ordinal)) + { + var info = oldSnapshot.TypeNames[t]; + entries.Add(new MemberChangeEntry("Removed", t, info.BaseType, info.Access, info.Modifiers, info.Kind, "", "", "", "", "")); + } + + // Methods (including constructors) + foreach (var key in newSnapshot.Methods.Keys.Except(oldSnapshot.Methods.Keys, StringComparer.Ordinal).OrderBy(k => k, StringComparer.Ordinal)) + { + var m = newSnapshot.Methods[key]; + string kind = ToMemberKind(m.MethodName); + entries.Add(new MemberChangeEntry("Added", m.TypeName, LookupBaseType(m.TypeName, newSnapshot, oldSnapshot), m.Access, m.Modifiers, kind, ToCSharpMethodName(m.MethodName, m.TypeName), "", m.ReturnType, m.Parameters, "")); + } + foreach (var key in oldSnapshot.Methods.Keys.Except(newSnapshot.Methods.Keys, StringComparer.Ordinal).OrderBy(k => k, StringComparer.Ordinal)) + { + var m = oldSnapshot.Methods[key]; + string kind = ToMemberKind(m.MethodName); + entries.Add(new MemberChangeEntry("Removed", m.TypeName, LookupBaseType(m.TypeName, oldSnapshot, newSnapshot), m.Access, m.Modifiers, kind, ToCSharpMethodName(m.MethodName, m.TypeName), "", m.ReturnType, m.Parameters, "")); + } + foreach (var key in oldSnapshot.Methods.Keys.Intersect(newSnapshot.Methods.Keys, StringComparer.Ordinal).OrderBy(k => k, StringComparer.Ordinal)) + { + if (!oldSnapshot.Methods[key].IlBytes.AsSpan().SequenceEqual(newSnapshot.Methods[key].IlBytes.AsSpan())) + { + var m = newSnapshot.Methods[key]; + string kind = ToMemberKind(m.MethodName); + entries.Add(new MemberChangeEntry("Modified", m.TypeName, LookupBaseType(m.TypeName, newSnapshot, oldSnapshot), m.Access, m.Modifiers, kind, ToCSharpMethodName(m.MethodName, m.TypeName), "", m.ReturnType, m.Parameters, "Changed")); + } + } + + // Properties + foreach (var key in newSnapshot.Properties.Keys.Except(oldSnapshot.Properties.Keys, StringComparer.Ordinal).OrderBy(k => k, StringComparer.Ordinal)) + { + var p = newSnapshot.Properties[key]; + entries.Add(new MemberChangeEntry("Added", p.TypeName, LookupBaseType(p.TypeName, newSnapshot, oldSnapshot), p.Access, p.Modifiers, "Property", p.PropertyName, p.PropertyType, "", "", "")); + } + foreach (var key in oldSnapshot.Properties.Keys.Except(newSnapshot.Properties.Keys, StringComparer.Ordinal).OrderBy(k => k, StringComparer.Ordinal)) + { + var p = oldSnapshot.Properties[key]; + entries.Add(new MemberChangeEntry("Removed", p.TypeName, LookupBaseType(p.TypeName, oldSnapshot, newSnapshot), p.Access, p.Modifiers, "Property", p.PropertyName, p.PropertyType, "", "", "")); + } + + // Fields + foreach (var key in newSnapshot.Fields.Keys.Except(oldSnapshot.Fields.Keys, StringComparer.Ordinal).OrderBy(k => k, StringComparer.Ordinal)) + { + var f = newSnapshot.Fields[key]; + entries.Add(new MemberChangeEntry("Added", f.TypeName, LookupBaseType(f.TypeName, newSnapshot, oldSnapshot), f.Access, f.Modifiers, "Field", f.FieldName, StripColonPrefix(f.Details), "", "", "")); + } + foreach (var key in oldSnapshot.Fields.Keys.Except(newSnapshot.Fields.Keys, StringComparer.Ordinal).OrderBy(k => k, StringComparer.Ordinal)) + { + var f = oldSnapshot.Fields[key]; + entries.Add(new MemberChangeEntry("Removed", f.TypeName, LookupBaseType(f.TypeName, oldSnapshot, newSnapshot), f.Access, f.Modifiers, "Field", f.FieldName, StripColonPrefix(f.Details), "", "", "")); + } + + // Sort: by TypeName, then by Change order (Added → Removed → Modified) + entries.Sort((a, b) => + { + int cmp = StringComparer.Ordinal.Compare(a.TypeName, b.TypeName); + if (cmp != 0) return cmp; + return ChangeOrder(a.Change).CompareTo(ChangeOrder(b.Change)); + }); + + return new AssemblySemanticChangesSummary + { + Entries = entries, + }; + } +#pragma warning disable CA1031 // ベストエフォート解析のため全例外をキャッチ / Catch-all for best-effort analysis + catch + { + return null; + } +#pragma warning restore CA1031 + } + + // ── Internal snapshot data ────────────────────────────────────────── + + private sealed class MethodDetail + { + public required string TypeName { get; init; } + public required string Access { get; init; } + public required string Modifiers { get; init; } + public required string MethodName { get; init; } + public required string ReturnType { get; init; } + public required string Parameters { get; init; } + public required byte[] IlBytes { get; init; } + } + + private sealed class PropertyDetail + { + public required string TypeName { get; init; } + public required string Access { get; init; } + public required string Modifiers { get; init; } + public required string PropertyName { get; init; } + public required string PropertyType { get; init; } + public required string Details { get; init; } + } + + private sealed class FieldDetail + { + public required string TypeName { get; init; } + public required string Access { get; init; } + public required string Modifiers { get; init; } + public required string FieldName { get; init; } + public required string Details { get; init; } + } + + private sealed class TypeInfo + { + public required string Access { get; init; } + public required string Kind { get; init; } + public required string BaseType { get; init; } + public required string Modifiers { get; init; } + } + + private sealed class AssemblySnapshot + { + public Dictionary TypeNames { get; } = new(StringComparer.Ordinal); + public Dictionary Methods { get; } = new(StringComparer.Ordinal); + public Dictionary Properties { get; } = new(StringComparer.Ordinal); + public Dictionary Fields { get; } = new(StringComparer.Ordinal); + } + + // ── Snapshot construction ─────────────────────────────────────────── + + private static AssemblySnapshot ReadAssemblySnapshot(string assemblyPath) + { + var snapshot = new AssemblySnapshot(); + using var stream = new FileStream(assemblyPath, FileMode.Open, FileAccess.Read, FileShare.Read); + using var peReader = new PEReader(stream); + var reader = peReader.GetMetadataReader(); + var typeProvider = new SimpleSignatureTypeProvider(reader); + + foreach (var typeHandle in reader.TypeDefinitions) + { + var typeDef = reader.GetTypeDefinition(typeHandle); + string typeName = GetFullTypeName(reader, typeDef); + + // Skip the special type + if (typeName == "") continue; + + string typeAccess = GetTypeAccessModifier(typeDef.Attributes); + string typeKind = GetTypeKind(reader, typeDef); + string baseType = GetBaseTypeDisplayName(reader, typeDef); + string typeModifiers = GetTypeModifiers(typeDef.Attributes); + snapshot.TypeNames[typeName] = new TypeInfo { Access = typeAccess, Kind = typeKind, BaseType = baseType, Modifiers = typeModifiers }; + + // Methods + foreach (var methodHandle in typeDef.GetMethods()) + { + var methodDef = reader.GetMethodDefinition(methodHandle); + string access = GetAccessModifier(methodDef.Attributes); + string modifiers = GetMethodModifiers(methodDef.Attributes); + string methodName = reader.GetString(methodDef.Name); + string matchKey = BuildMethodMatchKey(reader, typeName, methodDef, typeProvider); + var (retType, parameters) = BuildMethodSignatureParts(reader, methodDef, typeProvider); + byte[] ilBytes = ReadIlBytes(peReader, methodDef); + + snapshot.Methods[matchKey] = new MethodDetail + { + TypeName = typeName, + Access = access, + Modifiers = modifiers, + MethodName = methodName, + ReturnType = retType, + Parameters = parameters, + IlBytes = ilBytes, + }; + } + + // Properties + foreach (var propHandle in typeDef.GetProperties()) + { + var propDef = reader.GetPropertyDefinition(propHandle); + string propName = reader.GetString(propDef.Name); + string propKey = $"{typeName}::{propName}"; + string propAccess = GetPropertyAccess(reader, propDef); + string propModifiers = GetPropertyModifiers(reader, propDef); + string propType = BuildPropertyType(reader, propDef, typeProvider); + string propDetails = BuildPropertyDetails(reader, propDef, typeProvider); + + snapshot.Properties[propKey] = new PropertyDetail + { + TypeName = typeName, + Access = propAccess, + Modifiers = propModifiers, + PropertyName = propName, + PropertyType = propType, + Details = propDetails, + }; + } + + // Fields + foreach (var fieldHandle in typeDef.GetFields()) + { + var fieldDef = reader.GetFieldDefinition(fieldHandle); + string fieldName = reader.GetString(fieldDef.Name); + string fieldKey = $"{typeName}::{fieldName}"; + string fieldAccess = GetFieldAccessModifier(fieldDef.Attributes); + string fieldModifiers = GetFieldModifiers(fieldDef.Attributes); + string fieldDetails = BuildFieldDetails(reader, fieldDef, typeProvider); + + snapshot.Fields[fieldKey] = new FieldDetail + { + TypeName = typeName, + Access = fieldAccess, + Modifiers = fieldModifiers, + FieldName = fieldName, + Details = fieldDetails, + }; + } + } + + return snapshot; + } + + // ── Helpers ────────────────────────────────────────────────────────── + + private static string GetFullTypeName(MetadataReader reader, TypeDefinition typeDef) + { + string name = reader.GetString(typeDef.Name); + string ns = reader.GetString(typeDef.Namespace); + + if (typeDef.IsNested) + { + var declaringType = reader.GetTypeDefinition(typeDef.GetDeclaringType()); + string parentName = GetFullTypeName(reader, declaringType); + return $"{parentName}/{name}"; + } + + return string.IsNullOrEmpty(ns) ? name : $"{ns}.{name}"; + } + + /// Sort order for Change column: Added=0, Removed=1, Modified=2. / Change 列のソート順。 + private static int ChangeOrder(string change) + => change switch { "Added" => 0, "Removed" => 1, "Modified" => 2, _ => 3 }; + + /// Determine the member kind from the IL method name: .ctor → Constructor, .cctor → StaticConstructor, else → Method. / IL メソッド名からメンバー種別を判定。 + private static string ToMemberKind(string ilMethodName) + => ilMethodName switch + { + ".ctor" => "Constructor", + ".cctor" => "StaticConstructor", + _ => "Method" + }; + + /// Convert IL method name to C# name: .ctor/.cctor → simple class name. / IL メソッド名を C# 名に変換。 + private static string ToCSharpMethodName(string ilMethodName, string typeName) + { + if (ilMethodName is ".ctor" or ".cctor") + { + // Extract simple class name from potentially nested or namespaced type name + int slashIdx = typeName.LastIndexOf('/'); + string leaf = slashIdx >= 0 ? typeName[(slashIdx + 1)..] : typeName; + int dotIdx = leaf.LastIndexOf('.'); + return dotIdx >= 0 ? leaf[(dotIdx + 1)..] : leaf; + } + return ilMethodName; + } + + /// Strip leading ": " prefix from field/property details to extract the type portion. / フィールド・プロパティの詳細から先頭 ": " を除去して型部分を抽出。 + private static string StripColonPrefix(string details) + => details.StartsWith(": ", StringComparison.Ordinal) ? details[2..] : details; + + /// Build a key for method matching (without access modifier so access changes don't cause false add/remove pairs). + private static string BuildMethodMatchKey(MetadataReader reader, string typeName, MethodDefinition methodDef, SimpleSignatureTypeProvider typeProvider) + { + string methodName = reader.GetString(methodDef.Name); + + try + { + var sigBlobReader = reader.GetBlobReader(methodDef.Signature); + var decoder = new SignatureDecoder(typeProvider, reader, genericContext: null); + var signature = decoder.DecodeMethodSignature(ref sigBlobReader); + string parameters = string.Join(", ", signature.ParameterTypes); + return $"{typeName}::{methodName}({parameters}) : {signature.ReturnType}"; + } +#pragma warning disable CA1031 // シグネチャデコード失敗時のフォールバック / Fallback when signature decoding fails + catch + { + var sigBytes = reader.GetBlobBytes(methodDef.Signature); + return $"{typeName}::{methodName}(#{Convert.ToHexString(sigBytes)})"; + } +#pragma warning restore CA1031 + } + + /// + /// Build separate return type and parameter strings for a method. + /// メソッドの戻り値型とパラメータ文字列を個別に構築します。 + /// + private static (string ReturnType, string Parameters) BuildMethodSignatureParts(MetadataReader reader, MethodDefinition methodDef, SimpleSignatureTypeProvider typeProvider) + { + try + { + var sigBlobReader = reader.GetBlobReader(methodDef.Signature); + var decoder = new SignatureDecoder(typeProvider, reader, genericContext: null); + var signature = decoder.DecodeMethodSignature(ref sigBlobReader); + + // Collect parameter metadata (names and default values) + var paramsBySeq = new Dictionary(); + foreach (var paramHandle in methodDef.GetParameters()) + { + var param = reader.GetParameter(paramHandle); + if (param.SequenceNumber > 0) + paramsBySeq[param.SequenceNumber] = param; + } + + var parts = new List(); + for (int i = 0; i < signature.ParameterTypes.Length; i++) + { + string paramType = signature.ParameterTypes[i]; + string part; + + if (paramsBySeq.TryGetValue(i + 1, out var param)) + { + string paramName = reader.GetString(param.Name); + part = string.IsNullOrEmpty(paramName) ? paramType : $"{paramType} {paramName}"; + + var defaultHandle = param.GetDefaultValue(); + if (!defaultHandle.IsNil) + { + string defaultVal = ReadConstantValue(reader, defaultHandle); + if (!string.IsNullOrEmpty(defaultVal)) + part += $" = {defaultVal}"; + } + } + else + { + part = paramType; + } + parts.Add(part); + } + + return (signature.ReturnType, string.Join(", ", parts)); + } +#pragma warning disable CA1031 + catch + { + return ("", ""); + } +#pragma warning restore CA1031 + } + + /// Extract the declared type of a property (without accessor info). / プロパティの宣言型を抽出(アクセサ情報なし)。 + private static string BuildPropertyType(MetadataReader reader, PropertyDefinition propDef, SimpleSignatureTypeProvider typeProvider) + { + try + { + var sigBlobReader = reader.GetBlobReader(propDef.Signature); + var decoder = new SignatureDecoder(typeProvider, reader, genericContext: null); + var signature = decoder.DecodeMethodSignature(ref sigBlobReader); + return signature.ReturnType; + } +#pragma warning disable CA1031 + catch + { + return ""; + } +#pragma warning restore CA1031 + } + + /// Build property details: ": Type { get; set; }". + private static string BuildPropertyDetails(MetadataReader reader, PropertyDefinition propDef, SimpleSignatureTypeProvider typeProvider) + { + try + { + var sigBlobReader = reader.GetBlobReader(propDef.Signature); + var decoder = new SignatureDecoder(typeProvider, reader, genericContext: null); + var signature = decoder.DecodeMethodSignature(ref sigBlobReader); + string propType = signature.ReturnType; + + var accessors = propDef.GetAccessors(); + string accessorInfo = (!accessors.Getter.IsNil, !accessors.Setter.IsNil) switch + { + (true, true) => " { get; set; }", + (true, false) => " { get; }", + (false, true) => " { set; }", + _ => "" + }; + + return $": {propType}{accessorInfo}"; + } +#pragma warning disable CA1031 + catch + { + return ""; + } +#pragma warning restore CA1031 + } + + /// Build field details: ": Type" or ": Type = defaultValue". + private static string BuildFieldDetails(MetadataReader reader, FieldDefinition fieldDef, SimpleSignatureTypeProvider typeProvider) + { + try + { + string fieldType = fieldDef.DecodeSignature(typeProvider, null); + string result = $": {fieldType}"; + + var defaultHandle = fieldDef.GetDefaultValue(); + if (!defaultHandle.IsNil) + { + string defaultVal = ReadConstantValue(reader, defaultHandle); + if (!string.IsNullOrEmpty(defaultVal)) + result += $" = {defaultVal}"; + } + + return result; + } +#pragma warning disable CA1031 + catch + { + return ""; + } +#pragma warning restore CA1031 + } + + private static string GetPropertyAccess(MetadataReader reader, PropertyDefinition propDef) + { + var accessors = propDef.GetAccessors(); + if (!accessors.Getter.IsNil) + return GetAccessModifier(reader.GetMethodDefinition(accessors.Getter).Attributes); + if (!accessors.Setter.IsNil) + return GetAccessModifier(reader.GetMethodDefinition(accessors.Setter).Attributes); + return ""; + } + + private static string GetAccessModifier(MethodAttributes attributes) + { + var access = attributes & MethodAttributes.MemberAccessMask; + return access switch + { + MethodAttributes.Public => "public", + MethodAttributes.Family => "protected", + MethodAttributes.FamORAssem => "protected internal", + MethodAttributes.Assembly => "internal", + MethodAttributes.FamANDAssem => "private protected", + MethodAttributes.Private => "private", + _ => "private" + }; + } + + private static string GetFieldAccessModifier(FieldAttributes attributes) + { + var access = attributes & FieldAttributes.FieldAccessMask; + return access switch + { + FieldAttributes.Public => "public", + FieldAttributes.Family => "protected", + FieldAttributes.FamORAssem => "protected internal", + FieldAttributes.Assembly => "internal", + FieldAttributes.FamANDAssem => "private protected", + FieldAttributes.Private => "private", + _ => "private" + }; + } + + /// Extract access modifier from type attributes. / TypeAttributes からアクセス修飾子を取得。 + private static string GetTypeAccessModifier(System.Reflection.TypeAttributes attributes) + { + var visibility = attributes & System.Reflection.TypeAttributes.VisibilityMask; + return visibility switch + { + System.Reflection.TypeAttributes.Public => "public", + System.Reflection.TypeAttributes.NotPublic => "internal", + System.Reflection.TypeAttributes.NestedPublic => "public", + System.Reflection.TypeAttributes.NestedFamily => "protected", + System.Reflection.TypeAttributes.NestedFamORAssem => "protected internal", + System.Reflection.TypeAttributes.NestedAssembly => "internal", + System.Reflection.TypeAttributes.NestedFamANDAssem => "private protected", + System.Reflection.TypeAttributes.NestedPrivate => "private", + _ => "internal" + }; + } + + /// Extract non-access modifiers from type attributes (sealed, abstract, static). / TypeAttributes から非アクセス修飾子を取得。 + private static string GetTypeModifiers(System.Reflection.TypeAttributes attributes) + { + var parts = new List(); + bool isAbstract = (attributes & System.Reflection.TypeAttributes.Abstract) != 0; + bool isSealed = (attributes & System.Reflection.TypeAttributes.Sealed) != 0; + bool isInterface = (attributes & System.Reflection.TypeAttributes.Interface) != 0; + + if (isAbstract && isSealed && !isInterface) + parts.Add("static"); // static classes are abstract + sealed in IL + else if (isSealed && !isInterface) + parts.Add("sealed"); + else if (isAbstract && !isInterface) + parts.Add("abstract"); + + return string.Join(" ", parts); + } + + /// + /// Determine the type kind: Class, Record, Struct, Interface, or Enum. + /// Record is detected heuristically by the presence of an EqualityContract property. + /// 型の種別を判定: Class, Record, Struct, Interface, Enum。 + /// Record は EqualityContract プロパティの有無で推定。 + /// + private static string GetTypeKind(MetadataReader reader, TypeDefinition typeDef) + { + var attributes = typeDef.Attributes; + + if ((attributes & System.Reflection.TypeAttributes.Interface) != 0) + return "Interface"; + + // Check base type for enum / struct (value type) + if (!typeDef.BaseType.IsNil) + { + string baseTypeName = GetBaseTypeName(reader, typeDef.BaseType); + if (baseTypeName is "System.Enum") + return "Enum"; + if (baseTypeName is "System.ValueType") + return "Struct"; + } + + // Heuristic: C# records have a compiler-generated EqualityContract property + foreach (var propHandle in typeDef.GetProperties()) + { + var propDef = reader.GetPropertyDefinition(propHandle); + if (reader.GetString(propDef.Name) == "EqualityContract") + return "Record"; + } + + return "Class"; + } + + /// + /// Get the base type and implemented interfaces display string for a type definition, + /// omitting trivial bases (System.Object, System.ValueType, System.Enum) since those + /// are implied by the type kind. Returns e.g. "MyApp.BaseController, System.IDisposable". + /// 型定義の基底型および実装インターフェースの表示文字列を取得。自明な基底型は省略。 + /// + private static string GetBaseTypeDisplayName(MetadataReader reader, TypeDefinition typeDef) + { + var parts = new List(); + + // Base type (skip trivial) + if (!typeDef.BaseType.IsNil) + { + string baseTypeName = GetBaseTypeName(reader, typeDef.BaseType); + if (baseTypeName is not ("System.Object" or "System.ValueType" or "System.Enum" or "")) + parts.Add(baseTypeName); + } + + // Implemented interfaces + foreach (var ifaceHandle in typeDef.GetInterfaceImplementations()) + { + var iface = reader.GetInterfaceImplementation(ifaceHandle); + string ifaceName = GetInterfaceTypeName(reader, iface.Interface); + if (!string.IsNullOrEmpty(ifaceName)) + parts.Add(ifaceName); + } + + return string.Join(", ", parts); + } + + /// Get the full name of an interface from its EntityHandle. + private static string GetInterfaceTypeName(MetadataReader reader, EntityHandle handle) + { + if (handle.Kind == HandleKind.TypeReference) + { + var typeRef = reader.GetTypeReference((TypeReferenceHandle)handle); + string ns = reader.GetString(typeRef.Namespace); + string name = reader.GetString(typeRef.Name); + return string.IsNullOrEmpty(ns) ? name : $"{ns}.{name}"; + } + if (handle.Kind == HandleKind.TypeDefinition) + { + var typeDef = reader.GetTypeDefinition((TypeDefinitionHandle)handle); + return GetFullTypeName(reader, typeDef); + } + return ""; + } + + /// Get the full name of a base type from its EntityHandle. + private static string GetBaseTypeName(MetadataReader reader, EntityHandle baseTypeHandle) + { + if (baseTypeHandle.Kind == HandleKind.TypeReference) + { + var typeRef = reader.GetTypeReference((TypeReferenceHandle)baseTypeHandle); + string ns = reader.GetString(typeRef.Namespace); + string name = reader.GetString(typeRef.Name); + return string.IsNullOrEmpty(ns) ? name : $"{ns}.{name}"; + } + if (baseTypeHandle.Kind == HandleKind.TypeDefinition) + { + var typeDef = reader.GetTypeDefinition((TypeDefinitionHandle)baseTypeHandle); + return GetFullTypeName(reader, typeDef); + } + return ""; + } + + /// Extract non-access modifiers from method attributes (static, abstract, virtual, sealed, override, etc.). + private static string GetMethodModifiers(MethodAttributes attributes) + { + var parts = new List(); + if ((attributes & MethodAttributes.Static) != 0) parts.Add("static"); + if ((attributes & MethodAttributes.Abstract) != 0) + parts.Add("abstract"); + else if ((attributes & MethodAttributes.Final) != 0 && (attributes & MethodAttributes.Virtual) != 0 && (attributes & MethodAttributes.NewSlot) == 0) + parts.Add("sealed override"); + else if ((attributes & MethodAttributes.Virtual) != 0 && (attributes & MethodAttributes.NewSlot) != 0) + parts.Add("virtual"); + else if ((attributes & MethodAttributes.Virtual) != 0) + parts.Add("override"); + return string.Join(" ", parts); + } + + /// Extract modifiers for a property by inspecting its getter/setter method attributes. + private static string GetPropertyModifiers(MetadataReader reader, PropertyDefinition propDef) + { + var accessors = propDef.GetAccessors(); + MethodDefinition? accessor = !accessors.Getter.IsNil + ? reader.GetMethodDefinition(accessors.Getter) + : !accessors.Setter.IsNil ? reader.GetMethodDefinition(accessors.Setter) : null; + return accessor.HasValue ? GetMethodModifiers(accessor.Value.Attributes) : ""; + } + + /// Extract modifiers from field attributes (static, readonly, const, volatile). + private static string GetFieldModifiers(FieldAttributes attributes) + { + var parts = new List(); + if ((attributes & FieldAttributes.Static) != 0 && (attributes & FieldAttributes.Literal) != 0) + { + parts.Add("const"); + } + else + { + if ((attributes & FieldAttributes.Static) != 0) parts.Add("static"); + if ((attributes & FieldAttributes.InitOnly) != 0) parts.Add("readonly"); + } + return string.Join(" ", parts); + } + + private static byte[] ReadIlBytes(PEReader peReader, MethodDefinition methodDef) + { + if (methodDef.RelativeVirtualAddress == 0) return []; + + try + { + var body = peReader.GetMethodBody(methodDef.RelativeVirtualAddress); + return body.GetILBytes() ?? []; + } +#pragma warning disable CA1031 // ベストエフォートの IL バイト読み取り / Best-effort IL body read + catch + { + return []; + } +#pragma warning restore CA1031 + } + + private static string ReadConstantValue(MetadataReader reader, ConstantHandle handle) + { + if (handle.IsNil) return ""; + var constant = reader.GetConstant(handle); + var blobReader = reader.GetBlobReader(constant.Value); + + try + { + return constant.TypeCode switch + { + ConstantTypeCode.Boolean => blobReader.ReadBoolean() ? "true" : "false", + ConstantTypeCode.Char => $"'{(char)blobReader.ReadUInt16()}'", + ConstantTypeCode.SByte => blobReader.ReadSByte().ToString(), + ConstantTypeCode.Byte => blobReader.ReadByte().ToString(), + ConstantTypeCode.Int16 => blobReader.ReadInt16().ToString(), + ConstantTypeCode.UInt16 => blobReader.ReadUInt16().ToString(), + ConstantTypeCode.Int32 => blobReader.ReadInt32().ToString(), + ConstantTypeCode.UInt32 => blobReader.ReadUInt32().ToString(), + ConstantTypeCode.Int64 => blobReader.ReadInt64().ToString(), + ConstantTypeCode.UInt64 => blobReader.ReadUInt64().ToString(), + ConstantTypeCode.Single => blobReader.ReadSingle().ToString(), + ConstantTypeCode.Double => blobReader.ReadDouble().ToString(), + ConstantTypeCode.String => $"\"{blobReader.ReadSerializedString() ?? ""}\"", + ConstantTypeCode.NullReference => "null", + _ => "" + }; + } +#pragma warning disable CA1031 + catch + { + return ""; + } +#pragma warning restore CA1031 + } + + // ── Signature type provider ────────────────────────────────────────── + + /// + /// Minimal that decodes + /// method parameter and return types into human-readable strings. + /// メソッドパラメータおよび戻り値の型を可読文字列にデコードする最小限の実装。 + /// + internal sealed class SimpleSignatureTypeProvider : ISignatureTypeProvider + { + private readonly MetadataReader _reader; + + public SimpleSignatureTypeProvider(MetadataReader reader) => _reader = reader; + + public string GetPrimitiveType(PrimitiveTypeCode typeCode) + => typeCode switch + { + PrimitiveTypeCode.Void => "System.Void", + PrimitiveTypeCode.Boolean => "System.Boolean", + PrimitiveTypeCode.Char => "System.Char", + PrimitiveTypeCode.SByte => "System.SByte", + PrimitiveTypeCode.Byte => "System.Byte", + PrimitiveTypeCode.Int16 => "System.Int16", + PrimitiveTypeCode.UInt16 => "System.UInt16", + PrimitiveTypeCode.Int32 => "System.Int32", + PrimitiveTypeCode.UInt32 => "System.UInt32", + PrimitiveTypeCode.Int64 => "System.Int64", + PrimitiveTypeCode.UInt64 => "System.UInt64", + PrimitiveTypeCode.Single => "System.Single", + PrimitiveTypeCode.Double => "System.Double", + PrimitiveTypeCode.String => "System.String", + PrimitiveTypeCode.Object => "System.Object", + PrimitiveTypeCode.IntPtr => "System.IntPtr", + PrimitiveTypeCode.UIntPtr => "System.UIntPtr", + PrimitiveTypeCode.TypedReference => "System.TypedReference", + _ => typeCode.ToString() + }; + + public string GetTypeFromDefinition(MetadataReader reader, TypeDefinitionHandle handle, byte rawTypeKind) + { + var typeDef = reader.GetTypeDefinition(handle); + return GetFullTypeName(reader, typeDef); + } + + public string GetTypeFromReference(MetadataReader reader, TypeReferenceHandle handle, byte rawTypeKind) + { + var typeRef = reader.GetTypeReference(handle); + string ns = reader.GetString(typeRef.Namespace); + string name = reader.GetString(typeRef.Name); + return string.IsNullOrEmpty(ns) ? name : $"{ns}.{name}"; + } + + public string GetTypeFromSpecification(MetadataReader reader, object? genericContext, TypeSpecificationHandle handle, byte rawTypeKind) + { + var sigReader = reader.GetBlobReader(reader.GetTypeSpecification(handle).Signature); + return new SignatureDecoder(this, reader, genericContext).DecodeType(ref sigReader); + } + + public string GetSZArrayType(string elementType) => $"{elementType}[]"; + public string GetPointerType(string elementType) => $"{elementType}*"; + public string GetByReferenceType(string elementType) => $"{elementType}&"; + public string GetGenericInstantiation(string genericType, ImmutableArray typeArguments) + => $"{genericType}<{string.Join(", ", typeArguments)}>"; + public string GetGenericMethodParameter(object? genericContext, int index) => $"!!{index}"; + public string GetGenericTypeParameter(object? genericContext, int index) => $"!{index}"; + public string GetPinnedType(string elementType) => elementType; + public string GetModifiedType(string modifier, string unmodifiedType, bool isRequired) => unmodifiedType; + public string GetArrayType(string elementType, ArrayShape shape) + => $"{elementType}[{new string(',', shape.Rank - 1)}]"; + public string GetFunctionPointerType(MethodSignature signature) => "delegate*"; + } + } +} diff --git a/Services/FileDiffService.cs b/Services/FileDiffService.cs index 04d45466..e74590c3 100644 --- a/Services/FileDiffService.cs +++ b/Services/FileDiffService.cs @@ -125,6 +125,13 @@ public async Task FilesAreEqualAsync(string fileRelativePath, int maxParal fileRelativePath, areDotNetAssembliesEqual ? FileDiffResultLists.DiffDetailResult.ILMatch : FileDiffResultLists.DiffDetailResult.ILMismatch, disassemblerLabel); + + // Best-effort assembly semantic analysis for ILMismatch assemblies + if (!areDotNetAssembliesEqual && _config.ShouldIncludeAssemblySemanticChangesInReport) + { + TryAnalyzeAssemblySemanticChanges(fileRelativePath, file1AbsolutePath, file2AbsolutePath); + } + return areDotNetAssembliesEqual; } catch (InvalidOperationException ex) @@ -234,6 +241,32 @@ public async Task FilesAreEqualAsync(string fileRelativePath, int maxParal } } + /// + /// Best-effort assembly semantic analysis using System.Reflection.Metadata. + /// Failures are logged but do not affect the comparison result. + /// System.Reflection.Metadata を使用したベストエフォートのアセンブリセマンティック解析。 + /// 失敗してもファイル比較結果には影響しません。 + /// + private void TryAnalyzeAssemblySemanticChanges(string fileRelativePath, string oldPath, string newPath) + { + try + { + var summary = AssemblyMethodAnalyzer.Analyze(oldPath, newPath); + if (summary?.HasChanges == true) + { + _fileDiffResultLists.FileRelativePathToAssemblySemanticChanges[fileRelativePath] = summary; + } + } +#pragma warning disable CA1031 // ベストエフォート解析のため全例外をキャッチ / Catch-all for best-effort analysis + catch (Exception ex) + { + _logger.LogMessage(AppLogLevel.Warning, + $"Method-level analysis failed for '{fileRelativePath}': {ex.Message}", + shouldOutputMessageToConsole: false, ex); + } +#pragma warning restore CA1031 + } + private void LogExpectedFileDiffFailure(string file1AbsolutePath, string file2AbsolutePath, Exception exception) { _logger.LogMessage( diff --git a/Services/HtmlReport/HtmlReportGenerateService.Css.cs b/Services/HtmlReport/HtmlReportGenerateService.Css.cs index 9fcc3517..3e4ca125 100644 --- a/Services/HtmlReport/HtmlReportGenerateService.Css.cs +++ b/Services/HtmlReport/HtmlReportGenerateService.Css.cs @@ -53,7 +53,10 @@ private static string GetCss() .save-status { font-size: 12px; color: #86868b; } .empty { color: #999; font-size: 12px; margin-bottom: 0.8rem; } /* ── Column width CSS variables ──────────────────────────────────────── */ - :root { --col-reason-w: 10em; --col-notes-w: 10em; --col-path-w: 22em; --col-diff-w: 9em; --col-disasm-w: 28em; } + :root { --col-reason-w: 10em; --col-notes-w: 10em; --col-path-w: 22em; --col-diff-w: 9em; --col-disasm-w: 28em; + --sc-class-w: 14em; --sc-basetype-w: 16em; --sc-type-w: 12em; + --sc-name-w: 10em; --sc-rettype-w: 12em; --sc-params-w: 18em; --sc-body-w: 5em; + --sc-cnt-class-w: 22em; } col.col-no-g { width: 3.2em; } col.col-cb-g { width: 2.2em; } col.col-reason-g { width: var(--col-reason-w); } @@ -65,7 +68,7 @@ private static string GetCss() /* ── Data tables ─────────────────────────────────────────────────────── */ .table-scroll { overflow-x: auto; margin-bottom: 1.2rem; } table { border-collapse: collapse; width: 100%; margin-bottom: 1.2rem; } - table:not(.stat-table):not(.diff-table) { table-layout: fixed; width: 1px; margin-bottom: 0; } + table:not(.stat-table):not(.diff-table):not(.semantic-changes-table) { table-layout: fixed; width: 1px; margin-bottom: 0; } th { padding: 4px 6px; font-size: 12px; white-space: nowrap; overflow: hidden; text-align: left; border: 1px solid #bbb; color: #000; } th.th-resizable { position: relative; } @@ -132,7 +135,41 @@ private static string GetCss() tr.diff-trunc-tr { background: #fffbdd; } td.diff-trunc-td { color: #735c0f; padding: 2px 8px; font-style: italic; } p.diff-skipped { color: #735c0f; font-size: 12px; padding: 4px 8px; - background: #fffbdd; margin: 0; }"; + background: #fffbdd; margin: 0; } + /* ── Assembly semantic changes ─────────────────────────────────────── */ + .semantic-changes { padding: 6px 12px; font-size: 12px; overflow-x: auto; } + .semantic-changes p { margin: 4px 0 2px; } + table.semantic-changes-table { border-collapse: collapse; margin: 4px 0; font-size: 12px; table-layout: fixed; } + table.semantic-changes-table th { padding: 3px 8px; border: 1px solid #bbb; background: #98989d; color: #fff; font-size: 11px; text-align: left; white-space: nowrap; } + table.semantic-changes-table th.th-resizable { position: relative; } + table.semantic-changes-table td { padding: 2px 8px; border: 1px solid #e0e0e0; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; background: #fff; } + table.semantic-changes-table th.sc-col-cb { width: 3.2em; text-align: center; } + table.semantic-changes-table td.sc-col-cb { width: 3.2em; text-align: center; } + /* sc-detail: checkbox(1) Class(2) BaseType(3) Change(4) Kind(5) Access(6) Modifiers(7) Type(8)… */ + table.semantic-changes-table.sc-detail td:nth-child(4), table.semantic-changes-table.sc-detail td:nth-child(5), + table.semantic-changes-table.sc-detail td:nth-child(6), table.semantic-changes-table.sc-detail td:nth-child(7) { text-align: center; } + /* sc-count: Class(1) Change(2) Count(3) — no checkbox column */ + table.semantic-changes-table.sc-count td:nth-child(2) { text-align: center; } + table.semantic-changes-table.sc-count td:last-child { text-align: right; } + table.semantic-changes-table.sc-count tr.group-cont td:nth-child(1) { border-top: hidden; } + table.semantic-changes-table.sc-detail tr.group-cont td:nth-child(2) { border-top: hidden; } + table.semantic-changes-table.sc-detail tr.group-cont td:nth-child(3) { border-top: hidden; } + /* sc colgroup widths */ + col.sc-col-cb-g { width: 3.2em; } + col.sc-col-class-g { width: var(--sc-class-w); } + col.sc-col-basetype-g { width: var(--sc-basetype-w); } + col.sc-col-change-g { width: 7em; } + col.sc-col-kind-g { width: 10em; } + col.sc-col-access-g { width: 8em; } + col.sc-col-mods-g { width: 11em; } + col.sc-col-type-g { width: var(--sc-type-w); } + col.sc-col-name-g { width: var(--sc-name-w); } + col.sc-col-rettype-g { width: var(--sc-rettype-w); } + col.sc-col-params-g { width: var(--sc-params-w); } + col.sc-col-body-g { width: var(--sc-body-w); } + col.sc-cnt-class-g { width: var(--sc-cnt-class-w); } + col.sc-cnt-change-g { width: 7em; } + col.sc-cnt-count-g { width: 4em; }"; } } } diff --git a/Services/HtmlReport/HtmlReportGenerateService.Js.cs b/Services/HtmlReport/HtmlReportGenerateService.Js.cs index 787427ed..43221639 100644 --- a/Services/HtmlReport/HtmlReportGenerateService.Js.cs +++ b/Services/HtmlReport/HtmlReportGenerateService.Js.cs @@ -43,6 +43,7 @@ private static void AppendJs(StringBuilder sb, string storageKey, string reportD sb.AppendLine(" }"); sb.AppendLine(" initColResize();"); sb.AppendLine(" syncTableWidths();"); + sb.AppendLine(" syncScTableWidths();"); sb.AppendLine(" setupLazyDiff();"); sb.AppendLine(" });"); sb.AppendLine(); @@ -68,7 +69,7 @@ private static void AppendJs(StringBuilder sb, string storageKey, string reportD sb.AppendLine(" var openDetails = Array.from(document.querySelectorAll('details[open]'));"); sb.AppendLine(" openDetails.forEach(function(d){ d.removeAttribute('open'); });"); sb.AppendLine(" // 2. Capture current effective column widths to bake into reviewed HTML as defaults"); - sb.AppendLine(" var colVarNames = ['--col-reason-w','--col-notes-w','--col-path-w','--col-diff-w','--col-disasm-w'];"); + sb.AppendLine(" var colVarNames = ['--col-reason-w','--col-notes-w','--col-path-w','--col-diff-w','--col-disasm-w','--sc-class-w','--sc-basetype-w','--sc-type-w','--sc-name-w','--sc-rettype-w','--sc-params-w','--sc-body-w','--sc-cnt-class-w'];"); sb.AppendLine(" var cs = getComputedStyle(root);"); sb.AppendLine(" var curWidths = {};"); sb.AppendLine(" colVarNames.forEach(function(v){ curWidths[v] = (root.style.getPropertyValue(v) || cs.getPropertyValue(v)).trim(); });"); @@ -87,7 +88,15 @@ private static void AppendJs(StringBuilder sb, string storageKey, string reportD sb.AppendLine(" + '; --col-notes-w: ' + curWidths['--col-notes-w']"); sb.AppendLine(" + '; --col-path-w: ' + curWidths['--col-path-w']"); sb.AppendLine(" + '; --col-diff-w: ' + curWidths['--col-diff-w']"); - sb.AppendLine(" + '; --col-disasm-w: ' + curWidths['--col-disasm-w'] + '; }');"); + sb.AppendLine(" + '; --col-disasm-w: ' + curWidths['--col-disasm-w']"); + sb.AppendLine(" + '; --sc-class-w: ' + curWidths['--sc-class-w']"); + sb.AppendLine(" + '; --sc-basetype-w: ' + curWidths['--sc-basetype-w']"); + sb.AppendLine(" + '; --sc-type-w: ' + curWidths['--sc-type-w']"); + sb.AppendLine(" + '; --sc-name-w: ' + curWidths['--sc-name-w']"); + sb.AppendLine(" + '; --sc-rettype-w: ' + curWidths['--sc-rettype-w']"); + sb.AppendLine(" + '; --sc-params-w: ' + curWidths['--sc-params-w']"); + sb.AppendLine(" + '; --sc-body-w: ' + curWidths['--sc-body-w']"); + sb.AppendLine(" + '; --sc-cnt-class-w: ' + curWidths['--sc-cnt-class-w'] + '; }');"); sb.AppendLine(" // Remove inline col-var overrides from element (now baked into :root)"); sb.AppendLine(" html = html.replace(/(]*?) style=\"[^\"]*\"/, '$1');"); sb.AppendLine(" // Replace controls bar with reviewed banner"); @@ -109,7 +118,7 @@ private static void AppendJs(StringBuilder sb, string storageKey, string reportD sb.AppendLine(" document.querySelectorAll('input[type=\"text\"], textarea').forEach(function(inp){ inp.value=''; });"); sb.AppendLine(" // Reset column widths to defaults"); sb.AppendLine(" var root = document.documentElement;"); - sb.AppendLine(" ['--col-reason-w','--col-notes-w','--col-path-w','--col-diff-w','--col-disasm-w'].forEach(function(v){ root.style.removeProperty(v); });"); + sb.AppendLine(" ['--col-reason-w','--col-notes-w','--col-path-w','--col-diff-w','--col-disasm-w','--sc-class-w','--sc-basetype-w','--sc-type-w','--sc-name-w','--sc-rettype-w','--sc-params-w','--sc-body-w','--sc-cnt-class-w'].forEach(function(v){ root.style.removeProperty(v); });"); sb.AppendLine(" syncTableWidths();"); sb.AppendLine(" // Close all open diff/IL-diff details"); sb.AppendLine(" document.querySelectorAll('details[open]').forEach(function(d){ d.removeAttribute('open'); });"); @@ -135,7 +144,34 @@ private static void AppendJs(StringBuilder sb, string storageKey, string reportD sb.AppendLine(" if (!b64) return;"); sb.AppendLine(" d.removeAttribute('data-diff-html');"); sb.AppendLine(" d.removeEventListener('toggle', onToggle);"); - sb.AppendLine(" try { d.insertAdjacentHTML('beforeend', decodeDiffHtml(b64)); } catch(e) {}"); + sb.AppendLine(" try {"); + sb.AppendLine(" d.insertAdjacentHTML('beforeend', decodeDiffHtml(b64));"); + sb.AppendLine(" // Re-init column resize handles on newly rendered tables"); + sb.AppendLine(" d.querySelectorAll('th.th-resizable').forEach(function(th) {"); + sb.AppendLine(" if (th.querySelector('.col-resize-handle')) return;"); + sb.AppendLine(" initColResizeSingle(th);"); + sb.AppendLine(" });"); + sb.AppendLine(" syncScTableWidths();"); + sb.AppendLine(" // Wire up save events on new checkboxes"); + sb.AppendLine(" if (__savedState__ === null) {"); + sb.AppendLine(" d.querySelectorAll('input').forEach(function(el) {"); + sb.AppendLine(" el.addEventListener('change', autoSave);"); + sb.AppendLine(" });"); + sb.AppendLine(" }"); + sb.AppendLine(" // Restore state for new inputs"); + sb.AppendLine(" var toRestore = __savedState__ || JSON.parse(localStorage.getItem(__storageKey__) || 'null');"); + sb.AppendLine(" if (toRestore) {"); + sb.AppendLine(" d.querySelectorAll('input[id]').forEach(function(el) {"); + sb.AppendLine(" if (el.id in toRestore) {"); + sb.AppendLine(" if (el.type === 'checkbox') el.checked = Boolean(toRestore[el.id]);"); + sb.AppendLine(" else el.value = String(toRestore[el.id] || '');"); + sb.AppendLine(" }"); + sb.AppendLine(" });"); + sb.AppendLine(" }"); + sb.AppendLine(" if (__savedState__ !== null) {"); + sb.AppendLine(" d.querySelectorAll('input[type=\"checkbox\"]').forEach(function(cb){ cb.style.pointerEvents='none'; cb.style.cursor='default'; });"); + sb.AppendLine(" }"); + sb.AppendLine(" } catch(e) {}"); sb.AppendLine(" });"); sb.AppendLine(" });"); sb.AppendLine(" }"); @@ -151,41 +187,64 @@ private static void AppendJs(StringBuilder sb, string storageKey, string reportD sb.AppendLine(" + px('--col-reason-w', 10) + px('--col-notes-w', 10)"); sb.AppendLine(" + px('--col-path-w', 22) + px('--col-diff-w', 9)"); sb.AppendLine(" + px('--col-disasm-w', 28);"); - sb.AppendLine(" document.querySelectorAll('table:not(.stat-table):not(.diff-table)').forEach(function(t) {"); + sb.AppendLine(" document.querySelectorAll('table:not(.stat-table):not(.diff-table):not(.semantic-changes-table)').forEach(function(t) {"); sb.AppendLine(" t.style.width = w + 'px';"); sb.AppendLine(" });"); sb.AppendLine(" }"); sb.AppendLine(); + sb.AppendLine(" function syncScTableWidths() {"); + sb.AppendLine(" var scEmPx = 12;"); + sb.AppendLine(" var root = document.documentElement;"); + sb.AppendLine(" var px = function(v, fb) {"); + sb.AppendLine(" var s = root.style.getPropertyValue(v) || getComputedStyle(root).getPropertyValue(v);"); + sb.AppendLine(" return (parseFloat(s) || fb) * scEmPx;"); + sb.AppendLine(" };"); + sb.AppendLine(" var detW = 3.2 * scEmPx"); + sb.AppendLine(" + px('--sc-class-w', 14) + px('--sc-basetype-w', 16)"); + sb.AppendLine(" + 7 * scEmPx + 10 * scEmPx + 8 * scEmPx + 11 * scEmPx"); + sb.AppendLine(" + px('--sc-type-w', 12) + px('--sc-name-w', 10)"); + sb.AppendLine(" + px('--sc-rettype-w', 12) + px('--sc-params-w', 18)"); + sb.AppendLine(" + px('--sc-body-w', 5);"); + sb.AppendLine(" document.querySelectorAll('table.sc-detail').forEach(function(t) { t.style.width = detW + 'px'; });"); + sb.AppendLine(" var cntW = px('--sc-cnt-class-w', 22) + 7 * scEmPx + 4 * scEmPx;"); + sb.AppendLine(" document.querySelectorAll('table.sc-count').forEach(function(t) { t.style.width = cntW + 'px'; });"); + sb.AppendLine(" }"); + sb.AppendLine(); + sb.AppendLine(" function initColResizeSingle(th) {"); + sb.AppendLine(" var label = document.createElement('span');"); + sb.AppendLine(" label.className = 'th-label';"); + sb.AppendLine(" while (th.childNodes.length) label.appendChild(th.childNodes[0]);"); + sb.AppendLine(" th.appendChild(label);"); + sb.AppendLine(" var handle = document.createElement('div');"); + sb.AppendLine(" handle.className = 'col-resize-handle';"); + sb.AppendLine(" th.appendChild(handle);"); + sb.AppendLine(" var varName = th.dataset.colVar;"); + sb.AppendLine(" var isSc = !!th.closest('.semantic-changes-table');"); + sb.AppendLine(" handle.addEventListener('mousedown', function(e) {"); + sb.AppendLine(" e.preventDefault();"); + sb.AppendLine(" var startX = e.clientX;"); + sb.AppendLine(" var root = document.documentElement;"); + sb.AppendLine(" var emPx = isSc ? 12 : (parseFloat(getComputedStyle(root).fontSize) || 16);"); + sb.AppendLine(" var cur = root.style.getPropertyValue(varName) || getComputedStyle(root).getPropertyValue(varName);"); + sb.AppendLine(" var startPx = (parseFloat(cur) || 10) * emPx;"); + sb.AppendLine(" function onMove(ev) {"); + sb.AppendLine(" var newPx = Math.max(48, startPx + (ev.clientX - startX));"); + sb.AppendLine(" root.style.setProperty(varName, (newPx / emPx).toFixed(2) + 'em');"); + sb.AppendLine(" syncTableWidths();"); + sb.AppendLine(" if (isSc) syncScTableWidths();"); + sb.AppendLine(" }"); + sb.AppendLine(" function onUp() {"); + sb.AppendLine(" document.removeEventListener('mousemove', onMove);"); + sb.AppendLine(" document.removeEventListener('mouseup', onUp);"); + sb.AppendLine(" }"); + sb.AppendLine(" document.addEventListener('mousemove', onMove);"); + sb.AppendLine(" document.addEventListener('mouseup', onUp);"); + sb.AppendLine(" });"); + sb.AppendLine(" }"); + sb.AppendLine(); sb.AppendLine(" function initColResize() {"); sb.AppendLine(" document.querySelectorAll('th.th-resizable').forEach(function(th) {"); - sb.AppendLine(" // Wrap text in a block span so overflow:hidden clips reliably at column boundary"); - sb.AppendLine(" var label = document.createElement('span');"); - sb.AppendLine(" label.className = 'th-label';"); - sb.AppendLine(" while (th.childNodes.length) label.appendChild(th.childNodes[0]);"); - sb.AppendLine(" th.appendChild(label);"); - sb.AppendLine(" var handle = document.createElement('div');"); - sb.AppendLine(" handle.className = 'col-resize-handle';"); - sb.AppendLine(" th.appendChild(handle);"); - sb.AppendLine(" var varName = th.dataset.colVar;"); - sb.AppendLine(" handle.addEventListener('mousedown', function(e) {"); - sb.AppendLine(" e.preventDefault();"); - sb.AppendLine(" var startX = e.clientX;"); - sb.AppendLine(" var root = document.documentElement;"); - sb.AppendLine(" var emPx = parseFloat(getComputedStyle(root).fontSize) || 16;"); - sb.AppendLine(" var cur = root.style.getPropertyValue(varName) || getComputedStyle(root).getPropertyValue(varName);"); - sb.AppendLine(" var startPx = (parseFloat(cur) || 10) * emPx;"); - sb.AppendLine(" function onMove(ev) {"); - sb.AppendLine(" var newPx = Math.max(48, startPx + (ev.clientX - startX));"); - sb.AppendLine(" root.style.setProperty(varName, (newPx / emPx).toFixed(2) + 'em');"); - sb.AppendLine(" syncTableWidths();"); - sb.AppendLine(" }"); - sb.AppendLine(" function onUp() {"); - sb.AppendLine(" document.removeEventListener('mousemove', onMove);"); - sb.AppendLine(" document.removeEventListener('mouseup', onUp);"); - sb.AppendLine(" }"); - sb.AppendLine(" document.addEventListener('mousemove', onMove);"); - sb.AppendLine(" document.addEventListener('mouseup', onUp);"); - sb.AppendLine(" });"); + sb.AppendLine(" initColResizeSingle(th);"); sb.AppendLine(" });"); sb.AppendLine(" }"); sb.AppendLine(""); diff --git a/Services/HtmlReport/HtmlReportGenerateService.Sections.cs b/Services/HtmlReport/HtmlReportGenerateService.Sections.cs index 1e2291a2..d2d7eefc 100644 --- a/Services/HtmlReport/HtmlReportGenerateService.Sections.cs +++ b/Services/HtmlReport/HtmlReportGenerateService.Sections.cs @@ -208,6 +208,14 @@ private void AppendModifiedSection( string col6 = BuildDiffDetailDisplay(diffDetail); AppendFileRow(sb, "mod", idx, path, ts, col6, asm ?? ""); + // Method-level changes row (above IL diff) + if (config.ShouldIncludeAssemblySemanticChangesInReport && + diffDetail == FileDiffResultLists.DiffDetailResult.ILMismatch && + _fileDiffResultLists.FileRelativePathToAssemblySemanticChanges.TryGetValue(path, out var semanticChanges)) + { + AppendAssemblySemanticChangesRow(sb, idx, path, semanticChanges, config); + } + if (config.EnableInlineDiff && (diffDetail == FileDiffResultLists.DiffDetailResult.TextMismatch || diffDetail == FileDiffResultLists.DiffDetailResult.ILMismatch)) @@ -337,6 +345,134 @@ private void AppendInlineDiffRow( sb.AppendLine(""); } + private void AppendAssemblySemanticChangesRow( + StringBuilder sb, + int idx, + string assemblyPath, + AssemblySemanticChangesSummary summary, + ConfigSettings config, + string sectionPrefix = "mod") + { + int recordNo = idx + 1; + var contentBuilder = new StringBuilder(); + contentBuilder.AppendLine("
"); + + if (summary.Entries.Count > 0) + { + contentBuilder.AppendLine("
+ /// Verifies that Kind, Access, and Modifiers column body cells use code emphasis (like TextMatch). + /// Kind, Access, Modifiers 列ボディが code 強調表示を使用すること(TextMatch と同等)を確認する。 + /// + [Fact] + public void GenerateDiffReportHtml_AssemblySemanticChanges_KindAccessModifiersUseCodeEmphasis() + { + var (oldDir, newDir, reportDir) = MakeDirs("sc-emphasis"); + + _resultLists.AddModifiedFileRelativePath("lib.dll"); + _resultLists.RecordDiffDetail("lib.dll", FileDiffResultLists.DiffDetailResult.ILMismatch, "dotnet-ildasm (version: 0.12.0)"); + + _resultLists.FileRelativePathToAssemblySemanticChanges["lib.dll"] = new AssemblySemanticChangesSummary + { + Entries = new List + { + new("Modified", "MyApp.Svc", "", "public", "virtual", "Method", "Run", "", "void", "", "Changed"), + }, + }; + + var config = CreateConfig(enableInlineDiff: true, lazyRender: false); + config.ShouldIncludeAssemblySemanticChangesInReport = true; + _service.GenerateDiffReportHtml(oldDir, newDir, reportDir, + appVersion: "1.0", elapsedTimeString: null, + computerName: "test-host", config); + + var html = File.ReadAllText(Path.Combine(reportDir, HtmlReportGenerateService.DIFF_REPORT_HTML_FILE_NAME)); + // Kind, Access, and Modifiers must use emphasis (matching TextMatch in other tables) + // Kind、Access、Modifiers は 強調表示を使うこと(他テーブルの TextMatch と同様) + Assert.Contains("Method", html); // Kind + Assert.Contains("public", html); // Access + Assert.Contains("virtual", html); // Modifiers + Assert.Contains("Modified", html); // Change + Assert.Contains("Changed", html); // Body + } + + /// + /// Verifies the th.sc-col-cb CSS rule exists for proper checkbox column header styling. + /// th.sc-col-cb CSS ルールが存在しチェック列ヘッダが正しくスタイリングされることを確認する。 + /// + [Fact] + public void GenerateDiffReportHtml_AssemblySemanticChanges_ThScColCbCssRuleExists() + { + var (oldDir, newDir, reportDir) = MakeDirs("sc-th-css"); + var config = CreateConfig(); + + _service.GenerateDiffReportHtml(oldDir, newDir, reportDir, + appVersion: "1.0", elapsedTimeString: null, + computerName: "test-host", config); + + var html = File.ReadAllText(Path.Combine(reportDir, HtmlReportGenerateService.DIFF_REPORT_HTML_FILE_NAME)); + // Both th.sc-col-cb and td.sc-col-cb CSS rules must exist + // th.sc-col-cb と td.sc-col-cb 両方の CSS ルールが存在すること + Assert.Contains("table.semantic-changes-table th.sc-col-cb", html); + Assert.Contains("table.semantic-changes-table td.sc-col-cb", html); + } + + /// + /// Verifies the semantic changes table td cells have white background styling. + /// セマンティック変更テーブルの td セルに白背景スタイルが適用されていることを確認する。 + /// + [Fact] + public void GenerateDiffReportHtml_AssemblySemanticChanges_TdHasWhiteBackground() + { + var (oldDir, newDir, reportDir) = MakeDirs("sc-td-bg"); + var config = CreateConfig(); + + _service.GenerateDiffReportHtml(oldDir, newDir, reportDir, + appVersion: "1.0", elapsedTimeString: null, + computerName: "test-host", config); + + var html = File.ReadAllText(Path.Combine(reportDir, HtmlReportGenerateService.DIFF_REPORT_HTML_FILE_NAME)); + // td cells in the semantic-changes-table must have white background + // semantic-changes-table の td セルは白背景であること + Assert.Contains("background: #fff", html); + } + private static ConfigSettings CreateConfig(bool enableInlineDiff = true, bool lazyRender = false) => new() { IgnoredExtensions = new List(), diff --git a/FolderDiffIL4DotNet.Tests/Services/ReportGenerateServiceTests.cs b/FolderDiffIL4DotNet.Tests/Services/ReportGenerateServiceTests.cs index afe9997c..340ca465 100644 --- a/FolderDiffIL4DotNet.Tests/Services/ReportGenerateServiceTests.cs +++ b/FolderDiffIL4DotNet.Tests/Services/ReportGenerateServiceTests.cs @@ -754,6 +754,168 @@ public void GenerateDiffReport_WithIgnoredFilesNoneLocation_DoesNotBreakReport() Assert.True(File.Exists(Path.Combine(reportDir, "diff_report.md"))); } + // ── Assembly Semantic Changes / アセンブリ意味変更 ───────────────────── + + [Fact] + public void GenerateDiffReport_AssemblySemanticChanges_IncludedBetweenSummaryAndILCacheStats() + { + var oldDir = Path.Combine(_rootDir, "old-asc"); + var newDir = Path.Combine(_rootDir, "new-asc"); + var reportDir = Path.Combine(_rootDir, "report-asc"); + Directory.CreateDirectory(oldDir); + Directory.CreateDirectory(newDir); + Directory.CreateDirectory(reportDir); + + _resultLists.AddModifiedFileRelativePath("src/App.dll"); + _resultLists.RecordDiffDetail("src/App.dll", FileDiffResultLists.DiffDetailResult.ILMismatch, "dotnet-ildasm (version: 0.12.0)"); + + var summary = new AssemblySemanticChangesSummary + { + Entries = new List + { + new("Added", "MyApp.NewService", "", "public", "", "Class", "", "", "", "", ""), + new("Added", "MyApp.UserService", "", "public", "static", "Method", "ValidateToken", "", "bool", "string token", ""), + new("Added", "MyApp.UserService", "", "internal", "", "Method", "RefreshSession", "", "void", "int userId", ""), + new("Removed", "MyApp.UserService", "", "public", "virtual", "Method", "LegacyAuth", "", "void", "string key", ""), + new("Modified", "MyApp.UserService", "", "public", "", "Method", "Login", "", "bool", "string user, string pass", "Changed"), + new("Added", "MyApp.UserService", "", "public", "", "Property", "IsActive", "bool", "", "", ""), + new("Added", "MyApp.UserService", "", "private", "readonly", "Field", "_cache", "object", "", "", ""), + }, + }; + _resultLists.FileRelativePathToAssemblySemanticChanges["src/App.dll"] = summary; + + // Also add IL Cache Stats to verify ordering + var config = CreateConfig(); + config.ShouldIncludeILCacheStatsInReport = true; + var ilCache = new ILCache(ilCacheDirectoryAbsolutePath: string.Empty); + + _service.GenerateDiffReport( + oldDir, newDir, reportDir, + appVersion: "test", elapsedTimeString: null, computerName: "test-host", + config, ilCache); + + var reportText = File.ReadAllText(Path.Combine(reportDir, "diff_report.md")); + + // Content checks — table format + Assert.Contains("## Assembly Semantic Changes", reportText); + Assert.Contains("### src/App.dll", reportText); + Assert.Contains("| Class | BaseType | Change | Kind | Access | Modifiers | Type | Name | ReturnType | Parameters | Body |", reportText); + // First row shows class name; subsequent rows for same class are empty + Assert.Contains("| MyApp.NewService | | `Added` | `Class` | `public` | | | | | | |", reportText); + Assert.Contains("| MyApp.UserService | | `Added` | `Method` | `public` | `static` | | ValidateToken | bool | string\u00A0token | |", reportText); + Assert.Contains("| | | `Modified` | `Method` | `public` | | | Login | bool | string\u00A0user,\u00A0string\u00A0pass | `Changed` |", reportText); + Assert.Contains("| | | `Added` | `Property` | `public` | | bool | IsActive | | | |", reportText); + // Summary count table + Assert.Contains("| Class | Change | Count |", reportText); + Assert.Contains("| MyApp.NewService | `Added` | 1 |", reportText); + Assert.Contains("| MyApp.UserService | `Added` | 4 |", reportText); + Assert.Contains("| | `Removed` | 1 |", reportText); + Assert.Contains("| | `Modified` | 1 |", reportText); + + // Ordering: Summary < Assembly Semantic Changes < IL Cache Stats + int summaryIdx = reportText.IndexOf("## Summary", StringComparison.Ordinal); + int semanticIdx = reportText.IndexOf("## Assembly Semantic Changes", StringComparison.Ordinal); + int ilCacheIdx = reportText.IndexOf("## IL Cache Stats", StringComparison.Ordinal); + Assert.True(summaryIdx < semanticIdx, "Assembly Semantic Changes should appear after Summary"); + Assert.True(semanticIdx < ilCacheIdx, "Assembly Semantic Changes should appear before IL Cache Stats"); + } + + [Fact] + public void GenerateDiffReport_AssemblySemanticChanges_NotIncludedWhenDisabled() + { + var oldDir = Path.Combine(_rootDir, "old-asc-off"); + var newDir = Path.Combine(_rootDir, "new-asc-off"); + var reportDir = Path.Combine(_rootDir, "report-asc-off"); + Directory.CreateDirectory(oldDir); + Directory.CreateDirectory(newDir); + Directory.CreateDirectory(reportDir); + + _resultLists.FileRelativePathToAssemblySemanticChanges["src/App.dll"] = new AssemblySemanticChangesSummary + { + Entries = new List + { + new("Added", "Foo", "", "public", "", "Method", "Bar", "", "void", "", ""), + }, + }; + + var config = CreateConfig(); + config.ShouldIncludeAssemblySemanticChangesInReport = false; + _service.GenerateDiffReport( + oldDir, newDir, reportDir, + appVersion: "test", elapsedTimeString: null, computerName: "test-host", + config); + + var reportText = File.ReadAllText(Path.Combine(reportDir, "diff_report.md")); + Assert.DoesNotContain("## Assembly Semantic Changes", reportText); + } + + [Fact] + public void GenerateDiffReport_AssemblySemanticChanges_NotIncludedWhenNoChanges() + { + var oldDir = Path.Combine(_rootDir, "old-asc-empty"); + var newDir = Path.Combine(_rootDir, "new-asc-empty"); + var reportDir = Path.Combine(_rootDir, "report-asc-empty"); + Directory.CreateDirectory(oldDir); + Directory.CreateDirectory(newDir); + Directory.CreateDirectory(reportDir); + + // No method-level changes recorded + var config = CreateConfig(); + config.ShouldIncludeAssemblySemanticChangesInReport = true; + _service.GenerateDiffReport( + oldDir, newDir, reportDir, + appVersion: "test", elapsedTimeString: null, computerName: "test-host", + config); + + var reportText = File.ReadAllText(Path.Combine(reportDir, "diff_report.md")); + Assert.DoesNotContain("## Assembly Semantic Changes", reportText); + } + + /// + /// Verifies that multi-word Modifiers (e.g. "static literal") use non-breaking spaces in the Markdown report. + /// 複数語の Modifiers(例: "static literal")が Markdown レポートでノーブレークスペースを使用することを確認する。 + /// + [Fact] + public void GenerateDiffReport_AssemblySemanticChanges_MultiWordModifiersUseNonBreakingSpace() + { + var oldDir = Path.Combine(_rootDir, "old-asc-nbsp"); + var newDir = Path.Combine(_rootDir, "new-asc-nbsp"); + var reportDir = Path.Combine(_rootDir, "report-asc-nbsp"); + Directory.CreateDirectory(oldDir); + Directory.CreateDirectory(newDir); + Directory.CreateDirectory(reportDir); + + _resultLists.AddModifiedFileRelativePath("src/Lib.dll"); + _resultLists.RecordDiffDetail("src/Lib.dll", FileDiffResultLists.DiffDetailResult.ILMismatch, "dotnet-ildasm (version: 0.12.0)"); + + _resultLists.FileRelativePathToAssemblySemanticChanges["src/Lib.dll"] = new AssemblySemanticChangesSummary + { + Entries = new List + { + new("Added", "MyApp.Status", "", "public", "static literal", "Field", "Active", "System.Int32", "", "", ""), + new("Added", "MyApp.Status", "", "public", "static readonly", "Field", "Default", "System.TimeSpan", "", "", ""), + }, + }; + + var config = CreateConfig(); + config.ShouldIncludeAssemblySemanticChangesInReport = true; + _service.GenerateDiffReport( + oldDir, newDir, reportDir, + appVersion: "test", elapsedTimeString: null, computerName: "test-host", + config); + + var reportText = File.ReadAllText(Path.Combine(reportDir, "diff_report.md")); + + // Multi-word modifiers must use non-breaking space (U+00A0) to prevent wrapping + // 複数語修飾子は折り返し防止のためノーブレークスペース (U+00A0) を使用すること + Assert.Contains("`static\u00A0literal`", reportText); + Assert.Contains("`static\u00A0readonly`", reportText); + // Regular space must NOT appear in these modifier values + // これらの修飾子値に通常スペースが含まれないこと + Assert.DoesNotContain("`static literal`", reportText); + Assert.DoesNotContain("`static readonly`", reportText); + } + private static ConfigSettings CreateConfig() => new() { IgnoredExtensions = new List(), diff --git a/Models/AssemblySemanticChangesSummary.cs b/Models/AssemblySemanticChangesSummary.cs new file mode 100644 index 00000000..9d2fd149 --- /dev/null +++ b/Models/AssemblySemanticChangesSummary.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; + +namespace FolderDiffIL4DotNet.Models +{ + /// + /// Summarises assembly semantic changes detected between two builds of a .NET assembly. + /// Each change is represented as a structured . + /// .NET アセンブリの新旧ビルド間で検出されたセマンティック変更要約を保持します。 + /// 各変更は構造化された として表現されます。 + /// + public sealed class AssemblySemanticChangesSummary + { + /// All detected assembly semantic changes. / 検出されたすべてのセマンティック変更。 + public IReadOnlyList Entries { get; init; } = []; + + /// Whether any changes were detected. / 何らかの変更が検出されたかどうか。 + public bool HasChanges => Entries.Count > 0; + + /// Number of entries with Change="Added". / Change="Added" のエントリ数。 + public int AddedCount => CountByChange("Added"); + + /// Number of entries with Change="Removed". / Change="Removed" のエントリ数。 + public int RemovedCount => CountByChange("Removed"); + + /// Number of entries with Change="Modified". / Change="Modified" のエントリ数。 + public int ModifiedCount => CountByChange("Modified"); + + private int CountByChange(string change) + { + int count = 0; + foreach (var e in Entries) + if (string.Equals(e.Change, change, System.StringComparison.Ordinal)) + count++; + return count; + } + } +} diff --git a/Models/ConfigSettings.cs b/Models/ConfigSettings.cs index cfc5021a..56e0ddc1 100644 --- a/Models/ConfigSettings.cs +++ b/Models/ConfigSettings.cs @@ -97,6 +97,16 @@ public List TextFileExtensions /// public bool ShouldIncludeIgnoredFiles { get; set; } = true; + /// + /// Whether to include member-level change details (type/method/property/field additions, removals, + /// and method body changes) for ILMismatch assemblies in the diff report. + /// When true, an Assembly Semantic Changes section is inserted between Summary and IL Cache Stats. + /// ILMismatch と判定された .NET アセンブリについて、メンバーレベルの変更詳細 + /// (型・メソッド・プロパティ・フィールドの増減およびメソッドボディの変更)をレポートに出力するかどうか。 + /// true の場合、Summary セクションと IL Cache Stats セクションの間に Assembly Semantic Changes セクションを追加します。 + /// + public bool ShouldIncludeAssemblySemanticChangesInReport { get; set; } = true; + /// /// Whether to include IL cache statistics (hits, misses, hit rate, etc.) in the diff report. /// When true, an IL Cache Stats section is inserted between the Summary and Warnings sections. diff --git a/Models/FileDiffResultLists.cs b/Models/FileDiffResultLists.cs index 55c5575e..6d729563 100644 --- a/Models/FileDiffResultLists.cs +++ b/Models/FileDiffResultLists.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; namespace FolderDiffIL4DotNet.Models @@ -106,6 +107,12 @@ public sealed record DiffSummaryStatistics( /// public ConcurrentDictionary NewFileTimestampOlderThanOldWarnings { get; } = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + /// + /// Assembly semantic change summaries for ILMismatch files, keyed by file relative path. + /// ILMismatch ファイルに対するアセンブリセマンティック変更要約。キーはファイルの相対パス。 + /// + public ConcurrentDictionary FileRelativePathToAssemblySemanticChanges { get; } = new ConcurrentDictionary(StringComparer.Ordinal); + public bool HasAnyNewFileTimestampOlderThanOldWarning => !NewFileTimestampOlderThanOldWarnings.IsEmpty; /// @@ -167,6 +174,7 @@ public void ResetAll() DisassemblerToolVersions.Clear(); DisassemblerToolVersionsFromCache.Clear(); NewFileTimestampOlderThanOldWarnings.Clear(); + FileRelativePathToAssemblySemanticChanges.Clear(); } /// diff --git a/Models/MemberChangeEntry.cs b/Models/MemberChangeEntry.cs new file mode 100644 index 00000000..d3254382 --- /dev/null +++ b/Models/MemberChangeEntry.cs @@ -0,0 +1,30 @@ +namespace FolderDiffIL4DotNet.Models +{ + /// + /// Represents a single member-level change detected between two assembly builds. + /// 2 つのアセンブリビルド間で検出された単一のメンバーレベル変更を表します。 + /// + /// Change kind: "Added", "Removed", "Modified". / 変更種別。 + /// Owning type name (or the type itself for Type entries). / 所属型名(Type エントリの場合は型名そのもの)。 + /// Access modifier: public, internal, protected, private, etc. / アクセス修飾子。 + /// Other modifiers (static, abstract, virtual, sealed, override, etc.). / その他の修飾子。 + /// Member kind: "Class", "Record", "Struct", "Interface", "Enum", "Constructor", "StaticConstructor", "Method", "Property", "Field". / メンバー種別。 + /// Member name (C# name; constructors use the class name, not .ctor). Empty for Type entries. / メンバー名(C# 名、コンストラクタは .ctor ではなくクラス名)。Type の場合は空。 + /// For Field/Property: the declared type (e.g. "string", "int"). Empty for Method/Constructor/Type entries. / フィールド・プロパティの宣言型。メソッド・コンストラクタ・Type の場合は空。 + /// For Method: the return type (e.g. "void", "string"). For Constructor: "void". Empty for Type/Field/Property entries. / メソッドの戻り値型。コンストラクタは "void"。Type/Field/Property の場合は空。 + /// For Method/Constructor: the parameter list without parentheses (e.g. "int page", "string name, int count = 0"). Empty string for no-arg methods. Empty for Type/Field/Property entries. / メソッド・コンストラクタのパラメータ一覧(括弧なし)。引数なしは空文字列。Type/Field/Property の場合は空。 + /// Base type and implemented interfaces of the owning type (e.g. "MyApp.BaseController, System.IDisposable"). Omits trivial bases (System.Object, System.ValueType, System.Enum). / 所属型の基底型および実装インターフェース。自明な基底型は省略。 + /// "Changed" when the method body or field initializer IL has changed; otherwise empty. / メソッドボディまたはフィールド初期化子の IL が変更された場合 "Changed"、それ以外は空。 + public sealed record MemberChangeEntry( + string Change, + string TypeName, + string BaseType, + string Access, + string Modifiers, + string MemberKind, + string MemberName, + string MemberType, + string ReturnType, + string Parameters, + string Body); +} diff --git a/README.md b/README.md index ae9638c4..8cd803ce 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Developer-focused details (architecture, CI, tests, implementation cautions): | Need | Document | | --- | --- | | Product overview, setup, usage, and configuration | [README.md](README.md#readme-en-usage) | +| Assembly semantic change detection | [README.md](README.md#readme-en-assembly-semantic-changes) | | Runtime architecture, execution flow, DI scopes, and implementation guardrails | [doc/DEVELOPER_GUIDE.md](doc/DEVELOPER_GUIDE.md#guide-en-map) | | Test strategy, local test commands, coverage, and isolation rules | [doc/TESTING_GUIDE.md](doc/TESTING_GUIDE.md#testing-en-run-tests) | | Generated API reference from XML documentation comments | [api/index.md](api/index.md) via [docfx.json](docfx.json) | @@ -184,6 +185,42 @@ Important details: - If [`ShouldIgnoreILLinesContainingConfiguredStrings`](#config-en-shouldignoreillinescontainingconfiguredstrings) is `true`, lines containing any configured ignore string are also skipped during IL comparison. - If IL comparison itself fails, the run stops instead of silently falling back to a weaker comparison. + +## Assembly Semantic Changes + +When an assembly is classified as `ILMismatch`, the tool performs an additional **semantic analysis** using [`System.Reflection.Metadata`](https://learn.microsoft.com/dotnet/api/system.reflection.metadata) to identify exactly what changed at the member level. Results appear in the **Assembly Semantic Changes** section of the Markdown report and as an expandable inline row in the HTML report. + +### What is detected + +| Category | Detected changes | +|----------|-----------------| +| **Type** | Additions and removals (including nested types), with base type and implemented interfaces | +| **Method** | Additions, removals, and IL body modifications | +| **Property** | Additions and removals (with get/set accessor info) | +| **Field** | Additions and removals (with type and default value) | +| **Access** | `public`, `protected`, `internal`, `private`, `protected internal`, `private protected` | +| **Modifiers** | For types: `sealed`, `abstract`, `static`. For members: `static`, `abstract`, `virtual`, `override`, `sealed override`, `const`, `readonly` | + +### Report table columns + +| Column | Description | Example | +|--------|-------------|---------| +| Class | Fully qualified type name | `MyNamespace.MyClass` | +| BaseType | Base type and implemented interfaces (omits trivial bases like System.Object) | `MyApp.BaseController, System.IDisposable` | +| Change | `Added`, `Removed`, or `Modified` | `Added` | +| Kind | Member kind: `Class`, `Record`, `Struct`, `Interface`, `Enum`, `Constructor`, `StaticConstructor`, `Method`, `Property`, `Field` | `Method` | +| Access | Access modifier | `public` | +| Modifiers | Other modifiers (for types: `sealed`, `abstract`, `static`; for members: `static`, `virtual`, `override`, etc.) | `sealed` | +| Type | Declared type for Field/Property using fully qualified .NET type names (empty for Method/Constructor/Class/Record) | `System.Int32` | +| Name | Member name (constructors use the class name; empty for Class/Record/Struct/Interface/Enum entries) | `DoWork` | +| ReturnType | Return type for Method/Constructor using fully qualified .NET type names (empty for Field/Property/Class/Record) | `System.Void` | +| Parameters | Parameter list for Method/Constructor using fully qualified .NET type names (empty for Field/Property/Class/Record) | `System.String name, System.Int32 count = 0` | +| Body | `Changed` when method body or field initializer IL has changed; otherwise empty | `Changed` | + +Controlled by [`ShouldIncludeAssemblySemanticChangesInReport`](#config-en-shouldincludeassemblysemanticchangesinreport) (default: `true`). + +A summary count table (`Class | Change | Count`) follows, grouping entries by class and change kind. Consecutive rows with the same class name suppress the class column for readability. + ## Configuration ([`config.json`](config.json)) Place [`config.json`](config.json) next to the executable. All keys are optional; omitted keys use the code-defined defaults in [`ConfigSettings`](Models/ConfigSettings.cs). If the defaults are acceptable, this file can be just: @@ -241,6 +278,11 @@ Override only the settings you want to change. For example: true Includes Ignored Files section before Unchanged.
ShouldIncludeAssemblySemanticChangesInReporttrueWhen true, includes an Assembly Semantic Changes section for ILMismatch assemblies between Summary and IL Cache Stats. Uses System.Reflection.Metadata to detect type/method/property/field additions, removals, and method body changes. In the HTML report, this appears as an expandable inline row above the IL diff.
ShouldIncludeILCacheStatsInReport falsetrue レポートに Ignored Files セクションを出力するか。
ShouldIncludeAssemblySemanticChangesInReporttruetrue の場合、ILMismatch と判定された .NET アセンブリについて、SummaryIL Cache Stats の間に Assembly Semantic Changes セクションを出力します。System.Reflection.Metadata を使用して型・メソッド・プロパティ・フィールドの増減およびメソッドボディの変更を検出します。HTML レポートでは IL diff の上に展開可能なインライン行として表示されます。
ShouldIncludeILCacheStatsInReport false
"); + contentBuilder.AppendLine(""); + contentBuilder.AppendLine(" "); + contentBuilder.AppendLine(" "); + contentBuilder.AppendLine(" "); + contentBuilder.AppendLine(" "); + contentBuilder.AppendLine(" "); + contentBuilder.AppendLine(" "); + contentBuilder.AppendLine(" "); + contentBuilder.AppendLine(" "); + contentBuilder.AppendLine(" "); + contentBuilder.AppendLine(" "); + contentBuilder.AppendLine(" "); + contentBuilder.AppendLine(" "); + contentBuilder.AppendLine(""); + contentBuilder.AppendLine(""); + contentBuilder.AppendLine(" "); + contentBuilder.AppendLine(" "); + contentBuilder.AppendLine(" "); + contentBuilder.AppendLine(" "); + contentBuilder.AppendLine(" "); + contentBuilder.AppendLine(" "); + contentBuilder.AppendLine(" "); + contentBuilder.AppendLine(" "); + contentBuilder.AppendLine(" "); + contentBuilder.AppendLine(""); + contentBuilder.AppendLine(""); + string prevType = ""; + int scRowIdx = 0; + foreach (var e in summary.Entries) + { + bool isCont = e.TypeName == prevType; + string classTd = !isCont ? HtmlEncode(e.TypeName) : ""; + string baseTypeTd = !isCont ? HtmlEncode(e.BaseType) : ""; + prevType = e.TypeName; + string trOpen = isCont ? "" : ""; + string accessTd = e.Access.Length > 0 ? $"{HtmlEncode(e.Access)}" : ""; + string modifiersTd = e.Modifiers.Length > 0 ? $"{HtmlEncode(e.Modifiers)}" : ""; + string bodyTd = e.Body.Length > 0 ? $"{HtmlEncode(e.Body)}" : ""; + string cbId = $"sc_{sectionPrefix}_{idx}_{scRowIdx}"; + contentBuilder.AppendLine($"{trOpen}"); + scRowIdx++; + } + contentBuilder.AppendLine("
ClassBaseTypeChangeKindAccessModifiersTypeNameReturnTypeParametersBody
{classTd}{baseTypeTd}{HtmlEncode(e.Change)}{HtmlEncode(e.MemberKind)}{accessTd}{modifiersTd}{HtmlEncode(e.MemberType)}{HtmlEncode(e.MemberName)}{HtmlEncode(e.ReturnType)}{HtmlEncode(e.Parameters)}{bodyTd}
"); + } + else + { + contentBuilder.AppendLine("

No structural changes detected. See IL diff for implementation-level differences.

"); + } + + if (summary.Entries.Count > 0) + AppendSummaryCountTable(contentBuilder, summary); + contentBuilder.AppendLine(""); + + string detailsId = $"semantic_{sectionPrefix}_{idx}"; + string summaryText = $"#{recordNo} Show assembly semantic changes"; + string summaryLabel = $" {HtmlEncode(summaryText)}"; + string contentHtml = contentBuilder.ToString(); + + sb.AppendLine(""); + sb.AppendLine(" "); + if (config.InlineDiffLazyRender) + { + string b64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(contentHtml)); + sb.AppendLine($"
"); + sb.AppendLine(summaryLabel); + sb.AppendLine("
"); + } + else + { + sb.AppendLine($"
"); + sb.AppendLine(summaryLabel); + sb.Append(contentHtml); + sb.AppendLine("
"); + } + sb.AppendLine(" "); + sb.AppendLine(""); + } + + private static void AppendSummaryCountTable(StringBuilder sb, AssemblySemanticChangesSummary summary) + { + var counts = new Dictionary<(string TypeName, string Change), int>(); + foreach (var e in summary.Entries) + { + var key = (e.TypeName, e.Change); + counts[key] = counts.TryGetValue(key, out int c) ? c + 1 : 1; + } + + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(" "); + sb.AppendLine(" "); + sb.AppendLine(" "); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(" "); + sb.AppendLine(" "); + sb.AppendLine(""); + sb.AppendLine(""); + string prevType = ""; + foreach (var ((typeName, change), count) in counts.OrderBy(kv => kv.Key.TypeName, StringComparer.Ordinal).ThenBy(kv => ChangeOrder(kv.Key.Change))) + { + bool isCont = typeName == prevType; + string classTd = !isCont ? HtmlEncode(typeName) : ""; + prevType = typeName; + string trOpen = isCont ? "" : ""; + sb.AppendLine($"{trOpen}"); + } + sb.AppendLine("
ClassChangeCount
{classTd}{HtmlEncode(change)}{count}
"); + } + + private static int ChangeOrder(string change) + => change switch { "Added" => 0, "Removed" => 1, "Modified" => 2, _ => 3 }; + private void AppendSummarySection(StringBuilder sb, ConfigSettings config) { sb.AppendLine("

Summary

"); diff --git a/Services/ReportGenerateService.SectionWriters.cs b/Services/ReportGenerateService.SectionWriters.cs index 1994ad63..9f478243 100644 --- a/Services/ReportGenerateService.SectionWriters.cs +++ b/Services/ReportGenerateService.SectionWriters.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using FolderDiffIL4DotNet.Common; @@ -191,6 +192,85 @@ public void Write(StreamWriter writer, ReportWriteContext ctx) } } + /// Writes the Assembly Semantic Changes section for ILMismatch assemblies. / ILMismatch アセンブリのアセンブリ意味変更セクションを書き込みます。 + private sealed class AssemblySemanticChangesSectionWriter : IReportSectionWriter + { + public void Write(StreamWriter writer, ReportWriteContext ctx) + { + if (!ctx.Config.ShouldIncludeAssemblySemanticChangesInReport) return; + var changes = ctx.FileDiffResultLists.FileRelativePathToAssemblySemanticChanges; + if (changes.IsEmpty) return; + + writer.WriteLine(REPORT_SECTION_ASSEMBLY_SEMANTIC_CHANGES); + + foreach (var (filePath, summary) in changes.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase)) + { + writer.WriteLine($"\n### {filePath}"); + + if (summary.Entries.Count > 0) + { + writer.WriteLine(); + writer.WriteLine("| Class | BaseType | Change | Kind | Access | Modifiers | Type | Name | ReturnType | Parameters | Body |"); + writer.WriteLine("|-------|----------|:------:|:----:|:------:|:---------:|------|------|------------|------------|------|"); + string prevType = ""; + foreach (var e in summary.Entries) + { + bool isCont = e.TypeName == prevType; + string classCol = !isCont ? EscapeMdTable(e.TypeName) : ""; + string baseTypeCol = !isCont ? NoWrapMd(EscapeMdTable(e.BaseType)) : ""; + prevType = e.TypeName; + string access = e.Access.Length > 0 ? $"`{EscapeMdTable(e.Access)}`" : ""; + string modifiers = e.Modifiers.Length > 0 ? $"`{NoWrapMd(EscapeMdTable(e.Modifiers))}`" : ""; + string body = e.Body.Length > 0 ? $"`{EscapeMdTable(e.Body)}`" : ""; + writer.WriteLine($"| {classCol} | {baseTypeCol} | `{EscapeMdTable(e.Change)}` | `{EscapeMdTable(e.MemberKind)}` | {access} | {modifiers} | {NoWrapMd(EscapeMdTable(e.MemberType))} | {EscapeMdTable(e.MemberName)} | {NoWrapMd(EscapeMdTable(e.ReturnType))} | {NoWrapMd(EscapeMdTable(e.Parameters))} | {body} |"); + } + } + else + { + writer.WriteLine("- No structural changes detected. See IL diff for implementation-level differences."); + } + + if (summary.Entries.Count > 0) + { + writer.WriteLine(); + writer.WriteLine("| Class | Change | Count |"); + writer.WriteLine("|-------|:------:|------:|"); + WriteSummaryCountTable(writer, summary); + } + } + + writer.WriteLine(); + } + + private static void WriteSummaryCountTable(StreamWriter writer, AssemblySemanticChangesSummary summary) + { + var counts = new Dictionary<(string TypeName, string Change), int>(); + foreach (var e in summary.Entries) + { + var key = (e.TypeName, e.Change); + counts[key] = counts.TryGetValue(key, out int c) ? c + 1 : 1; + } + + string prevType = ""; + foreach (var ((typeName, change), count) in counts.OrderBy(kv => kv.Key.TypeName, StringComparer.Ordinal).ThenBy(kv => ChangeOrder(kv.Key.Change))) + { + string classCol = typeName != prevType ? EscapeMdTable(typeName) : ""; + prevType = typeName; + writer.WriteLine($"| {classCol} | `{EscapeMdTable(change)}` | {count} |"); + } + } + + private static int ChangeOrder(string change) + => change switch { "Added" => 0, "Removed" => 1, "Modified" => 2, _ => 3 }; + + + /// Escape pipe characters for Markdown table cells. / Markdown テーブルセル用にパイプ文字をエスケープ。 + private static string EscapeMdTable(string value) => value.Replace("|", "\\|"); + + /// Replace spaces with non-breaking spaces to prevent wrapping in Markdown table cells. / Markdown テーブルセル内の折り返しを防ぐためにスペースをノーブレークスペースに置換。 + private static string NoWrapMd(string value) => value.Replace(' ', '\u00A0'); + } + /// Writes the IL Cache Stats section (only when enabled and ilCache is non-null). / IL Cache Stats セクションを書き込みます。 private sealed class ILCacheStatsSectionWriter : IReportSectionWriter { diff --git a/Services/ReportGenerateService.cs b/Services/ReportGenerateService.cs index a3d218cd..d9963eaa 100644 --- a/Services/ReportGenerateService.cs +++ b/Services/ReportGenerateService.cs @@ -61,6 +61,7 @@ public ReportGenerateService(FileDiffResultLists fileDiffResultLists, ILoggerSer private const string REPORT_LOCATION_BOTH = "(old/new)"; private const string REPORT_TIMESTAMP_ARROW = " → "; private const string REPORT_SECTION_SUMMARY = REPORT_SECTION_PREFIX + "Summary"; + private const string REPORT_SECTION_ASSEMBLY_SEMANTIC_CHANGES = REPORT_SECTION_PREFIX + "Assembly Semantic Changes"; private const string REPORT_SECTION_IL_CACHE_STATS = REPORT_SECTION_PREFIX + "IL Cache Stats"; private const string WARNING_NEW_FILE_TIMESTAMP_OLDER_THAN_OLD = "One or more **modified** files in `new` have older last-modified timestamps than the corresponding files in `old`."; private const string REPORT_SECTION_WARNINGS = REPORT_SECTION_PREFIX + "Warnings"; @@ -173,6 +174,7 @@ private void WriteDiffReport( new RemovedFilesSectionWriter(), new ModifiedFilesSectionWriter(), new SummarySectionWriter(), + new AssemblySemanticChangesSectionWriter(), new ILCacheStatsSectionWriter(), new WarningsSectionWriter(), }; diff --git a/doc/DEVELOPER_GUIDE.md b/doc/DEVELOPER_GUIDE.md index e97bfa4d..2cdc79de 100644 --- a/doc/DEVELOPER_GUIDE.md +++ b/doc/DEVELOPER_GUIDE.md @@ -283,6 +283,7 @@ Why this matters: | [`Services/FileDiffService.cs`](../Services/FileDiffService.cs) | Per-file decision tree | MD5 -> 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, optional IL dump writing | Enforces same disassembler identity | +| [`Services/AssemblyMethodAnalyzer.cs`](../Services/AssemblyMethodAnalyzer.cs) | Method-level change detection via `System.Reflection.Metadata` | Best-effort; returns `null` on failure. Detects type/method/property/field additions and removals, and method body IL changes | | [`Services/DotNetDisassembleService.cs`](../Services/DotNetDisassembleService.cs) | Tool probing, disassembly execution, cache hit/store tracking, blacklist handling | Central tool boundary; delegates prefetch to [`ILCachePrefetcher`](../Services/ILCachePrefetcher.cs) | | [`Services/ILCachePrefetcher.cs`](../Services/ILCachePrefetcher.cs) | IL-cache prefetch (pre-hit verification for all candidate command/arg patterns) | Extracted from [`DotNetDisassembleService`](../Services/DotNetDisassembleService.cs); owns its own hit counter | | [`Services/DisassemblerHelper.cs`](../Services/DisassemblerHelper.cs) | Shared static helpers: command identification, candidate enumeration, executable path resolution | Used by both [`DotNetDisassembleService`](../Services/DotNetDisassembleService.cs) and [`ILCachePrefetcher`](../Services/ILCachePrefetcher.cs); no instance state | @@ -910,6 +911,7 @@ sequenceDiagram | [`Services/FileDiffService.cs`](../Services/FileDiffService.cs) | ファイル単位の判定木 | `MD5 -> 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 出力 | 同一逆アセンブラ制約を保証 | +| [`Services/AssemblyMethodAnalyzer.cs`](../Services/AssemblyMethodAnalyzer.cs) | `System.Reflection.Metadata` によるメソッドレベル変更検出 | ベストエフォート;失敗時は `null` を返す。型・メソッド・プロパティ・フィールドの増減およびメソッド本体の IL 変更を検出 | | [`Services/DotNetDisassembleService.cs`](../Services/DotNetDisassembleService.cs) | ツール探索、逆アセンブル実行、キャッシュヒット/ストア追跡、ブラックリスト | 外部ツール境界;プリフェッチは [`ILCachePrefetcher`](../Services/ILCachePrefetcher.cs) へ委譲 | | [`Services/ILCachePrefetcher.cs`](../Services/ILCachePrefetcher.cs) | IL キャッシュのプリフェッチ(全候補コマンド×引数パターンの事前ヒット確認) | [`DotNetDisassembleService`](../Services/DotNetDisassembleService.cs) から分離;独自のヒットカウンタを保持 | | [`Services/DisassemblerHelper.cs`](../Services/DisassemblerHelper.cs) | 共有静的ヘルパー:コマンド判定・候補列挙・実行ファイルパス解決 | [`DotNetDisassembleService`](../Services/DotNetDisassembleService.cs) と [`ILCachePrefetcher`](../Services/ILCachePrefetcher.cs) の両方が使用;インスタンス状態なし | diff --git a/doc/TESTING_GUIDE.md b/doc/TESTING_GUIDE.md index 638cbf0c..0d66b3bc 100644 --- a/doc/TESTING_GUIDE.md +++ b/doc/TESTING_GUIDE.md @@ -26,7 +26,7 @@ Current tree has `548` total tests in the latest full run (`dotnet test FolderDi | --- | --- | --- | | Entry and configuration | [`ProgramTests`](../FolderDiffIL4DotNet.Tests/ProgramTests.cs), [`ProgramRunnerTests`](../FolderDiffIL4DotNet.Tests/ProgramRunnerTests.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, code-defined config defaults and override behavior, MD5/timestamp warning console and report output, reflection-backed verification of internal IL cache defaults wired by [`ProgramRunner`](../ProgramRunner.cs); 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 | | 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) | Discovery filtering, auto-parallelism policy, 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, 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 | -| IL/disassembler behavior | [`ILOutputServiceTests`](../FolderDiffIL4DotNet.Tests/Services/ILOutputServiceTests.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) | Same-disassembler pairing, fallback behavior, 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 | +| IL/disassembler behavior | [`ILOutputServiceTests`](../FolderDiffIL4DotNet.Tests/Services/ILOutputServiceTests.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) | Same-disassembler pairing, fallback behavior, 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 | | 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 MD5, and verifies that [`dotnet-ildasm`](https://www.nuget.org/packages/dotnet-ildasm/) still classifies them as `ILMatch` after MVID filtering | | Caching | [`ILCacheTests`](../FolderDiffIL4DotNet.Tests/Services/Caching/ILCacheTests.cs) | memory/disk cache semantics, same-key updates at capacity, eviction coordination, keying behavior | | Reporting/logging/progress | [`ReportGenerateServiceTests`](../FolderDiffIL4DotNet.Tests/Services/ReportGenerateServiceTests.cs), [`HtmlReportGenerateServiceTests`](../FolderDiffIL4DotNet.Tests/Services/HtmlReportGenerateServiceTests.cs), [`LoggerServiceTests`](../FolderDiffIL4DotNet.Tests/Services/LoggerServiceTests.cs), [`ProgressReportServiceTests`](../FolderDiffIL4DotNet.Tests/Services/ProgressReportServiceTests.cs) | report sections/summary formatting, report-only warning responsibility, HTML report file creation, interactive checkbox/input element presence, section colour coding, localStorage sentinel, `ShouldGenerateHtmlReport=false` skip, HTML encoding of special characters, inline diff summary `#N` numbering aligned with the leftmost table column, log output behavior, shared log-file/date formats, progress reporting lifecycle, Unicode filenames (Japanese/Umlaut/Chinese) in Modified and Unchanged sections, large-file-count (10,500) summary statistics correctness, `InlineDiffMaxDiffLines` suppression (diff computed first; skipped when diff output line count exceeds threshold) | @@ -196,7 +196,7 @@ Workflow/config files: [`.github/workflows/dotnet.yml`](../.github/workflows/dot | --- | --- | --- | | エントリーポイント/設定 | [`ProgramTests`](../FolderDiffIL4DotNet.Tests/ProgramTests.cs), [`ProgramRunnerTests`](../FolderDiffIL4DotNet.Tests/ProgramRunnerTests.cs), [`ConfigServiceTests`](../FolderDiffIL4DotNet.Tests/Services/ConfigServiceTests.cs), [`ConfigSettingsTests`](../FolderDiffIL4DotNet.Tests/Models/ConfigSettingsTests.cs) | `Main` の終了コード、引数不正と設定失敗を分ける [`ProgramRunner`](../ProgramRunner.cs) の型付き終了コード分類、引数検証と設定読込の順序、最小構成の実行、コード既定値と 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) メッセージへの行番号付与) | | 差分処理本体 | [`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) | 列挙フィルタ、自動並列度ポリシー、`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` のスナップショット正確性 | -| IL/逆アセンブラ | [`ILOutputServiceTests`](../FolderDiffIL4DotNet.Tests/Services/ILOutputServiceTests.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) | 同一逆アセンブラ比較、フォールバック、ブラックリスト(TTL 境界: 10 分のブラックリスト期間経過後にエントリが削除され、ツールが再試行されることを含む)、ツール独立状態、`RegisterFailure`/`ResetFailure` の null/空白文字ガード(null ガードの true 分岐を明示的にカバー)、存在しないコマンドの reset、32 スレッドの並行 `RegisterFailure`、TTL 切れ境界での並行呼び出し(例外なし)、検出・コマンド処理、判定失敗と非 .NET の区別;`ResolveExecutablePath` のブランチ網羅(ディレクトリ区切り文字を含む相対パス(存在あり/なし)、空白のみの `PATH` 環境変数、空文字列の PATH エントリ、PATH 検索によるコマンド発見);Windows 専用 `EnumerateExecutableNames` テスト(`.exe`/`.cmd`/`.bat` 拡張子を持つコマンドに重複拡張子が追加されないことを検証) | +| IL/逆アセンブラ | [`ILOutputServiceTests`](../FolderDiffIL4DotNet.Tests/Services/ILOutputServiceTests.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) | 同一逆アセンブラ比較、フォールバック、ブラックリスト(TTL 境界: 10 分のブラックリスト期間経過後にエントリが削除され、ツールが再試行されることを含む)、ツール独立状態、`RegisterFailure`/`ResetFailure` の null/空白文字ガード(null ガードの true 分岐を明示的にカバー)、存在しないコマンドの reset、32 スレッドの並行 `RegisterFailure`、TTL 切れ境界での並行呼び出し(例外なし)、検出・コマンド処理、判定失敗と非 .NET の区別;`ResolveExecutablePath` のブランチ網羅(ディレクトリ区切り文字を含む相対パス(存在あり/なし)、空白のみの `PATH` 環境変数、空文字列の PATH エントリ、PATH 検索によるコマンド発見);Windows 専用 `EnumerateExecutableNames` テスト(`.exe`/`.cmd`/`.bat` 拡張子を持つコマンドに重複拡張子が追加されないことを検証) | | 実逆アセンブラ E2E | [`RealDisassemblerE2ETests`](../FolderDiffIL4DotNet.Tests/Services/RealDisassemblerE2ETests.cs) | `Deterministic=false` の同一クラスライブラリを 2 回ビルドし、再ビルド DLL が MD5 では不一致でも、[`dotnet-ildasm`](https://www.nuget.org/packages/dotnet-ildasm/) では MVID 除外後に `ILMatch` になることを検証 | | キャッシュ | [`ILCacheTests`](../FolderDiffIL4DotNet.Tests/Services/Caching/ILCacheTests.cs) | メモリ/ディスクキャッシュの保持、同一キー再保存、退避時の連動削除、キー生成 | | レポート/ログ/進捗 | [`ReportGenerateServiceTests`](../FolderDiffIL4DotNet.Tests/Services/ReportGenerateServiceTests.cs)、[`HtmlReportGenerateServiceTests`](../FolderDiffIL4DotNet.Tests/Services/HtmlReportGenerateServiceTests.cs)、[`LoggerServiceTests`](../FolderDiffIL4DotNet.Tests/Services/LoggerServiceTests.cs)、[`ProgressReportServiceTests`](../FolderDiffIL4DotNet.Tests/Services/ProgressReportServiceTests.cs) | レポート出力内容、ログ動作、共有ログ書式、進捗報告ライフサイクル、HTML レポートのファイル生成・チェックボックス/入力要素の存在・セクション色付け・localStorage センチネル・`ShouldGenerateHtmlReport=false` スキップ・特殊文字の HTML エンコード、インライン差分サマリーの `#N` 番号と左端 `#` 列の整合、Modified・Unchanged セクションでの Unicode ファイル名(日本語/ウムラウト/中国語)のラウンドトリップ、10,500 件の大件数サマリー統計の正確性、`InlineDiffMaxDiffLines` による抑制(差分を計算した後、差分出力行数が閾値を超えた場合にスキップ) | diff --git a/doc/samples/diff_report.html b/doc/samples/diff_report.html index 32f76c2a..9ac5b1a9 100644 --- a/doc/samples/diff_report.html +++ b/doc/samples/diff_report.html @@ -51,7 +51,10 @@ .save-status { font-size: 12px; color: #86868b; } .empty { color: #999; font-size: 12px; margin-bottom: 0.8rem; } /* ── Column width CSS variables ──────────────────────────────────────── */ - :root { --col-reason-w: 10em; --col-notes-w: 10em; --col-path-w: 22em; --col-diff-w: 9em; --col-disasm-w: 28em; } + :root { --col-reason-w: 10em; --col-notes-w: 10em; --col-path-w: 22em; --col-diff-w: 9em; --col-disasm-w: 28em; + --sc-class-w: 14em; --sc-basetype-w: 16em; --sc-type-w: 12em; + --sc-name-w: 10em; --sc-rettype-w: 12em; --sc-params-w: 18em; --sc-body-w: 5em; + --sc-cnt-class-w: 22em; } col.col-no-g { width: 3.2em; } col.col-cb-g { width: 2.2em; } col.col-reason-g { width: var(--col-reason-w); } @@ -63,7 +66,7 @@ /* ── Data tables ─────────────────────────────────────────────────────── */ .table-scroll { overflow-x: auto; margin-bottom: 1.2rem; } table { border-collapse: collapse; width: 100%; margin-bottom: 1.2rem; } - table:not(.stat-table):not(.diff-table) { table-layout: fixed; width: 1px; margin-bottom: 0; } + table:not(.stat-table):not(.diff-table):not(.semantic-changes-table) { table-layout: fixed; width: 1px; margin-bottom: 0; } th { padding: 4px 6px; font-size: 12px; white-space: nowrap; overflow: hidden; text-align: left; border: 1px solid #bbb; color: #000; } th.th-resizable { position: relative; } @@ -131,6 +134,38 @@ td.diff-trunc-td { color: #735c0f; padding: 2px 8px; font-style: italic; } p.diff-skipped { color: #735c0f; font-size: 12px; padding: 4px 8px; background: #fffbdd; margin: 0; } + /* ── Assembly semantic changes ─────────────────────────────────────── */ + .semantic-changes { padding: 6px 12px; font-size: 12px; overflow-x: auto; } + .semantic-changes p { margin: 4px 0 2px; } + table.semantic-changes-table { border-collapse: collapse; margin: 4px 0; font-size: 12px; table-layout: fixed; } + table.semantic-changes-table th { padding: 3px 8px; border: 1px solid #bbb; background: #98989d; color: #fff; font-size: 11px; text-align: left; white-space: nowrap; } + table.semantic-changes-table th.th-resizable { position: relative; } + table.semantic-changes-table td { padding: 2px 8px; border: 1px solid #e0e0e0; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; background: #fff; } + table.semantic-changes-table th.sc-col-cb { width: 3.2em; text-align: center; } + table.semantic-changes-table td.sc-col-cb { width: 3.2em; text-align: center; } + table.semantic-changes-table.sc-detail td:nth-child(4), table.semantic-changes-table.sc-detail td:nth-child(5), + table.semantic-changes-table.sc-detail td:nth-child(6), table.semantic-changes-table.sc-detail td:nth-child(7) { text-align: center; } + /* sc-count: Class(1) Change(2) Count(3) — no checkbox column */ + table.semantic-changes-table.sc-count td:nth-child(2) { text-align: center; } + table.semantic-changes-table.sc-count td:last-child { text-align: right; } + table.semantic-changes-table.sc-count tr.group-cont td:nth-child(1) { border-top: hidden; } + table.semantic-changes-table.sc-detail tr.group-cont td:nth-child(2) { border-top: hidden; } + table.semantic-changes-table.sc-detail tr.group-cont td:nth-child(3) { border-top: hidden; } + col.sc-col-cb-g { width: 3.2em; } + col.sc-col-class-g { width: var(--sc-class-w); } + col.sc-col-basetype-g { width: var(--sc-basetype-w); } + col.sc-col-change-g { width: 7em; } + col.sc-col-kind-g { width: 10em; } + col.sc-col-access-g { width: 8em; } + col.sc-col-mods-g { width: 11em; } + col.sc-col-type-g { width: var(--sc-type-w); } + col.sc-col-name-g { width: var(--sc-name-w); } + col.sc-col-rettype-g { width: var(--sc-rettype-w); } + col.sc-col-params-g { width: var(--sc-params-w); } + col.sc-col-body-g { width: var(--sc-body-w); } + col.sc-cnt-class-g { width: var(--sc-cnt-class-w); } + col.sc-cnt-change-g { width: 7em; } + col.sc-cnt-count-g { width: 4em; } @@ -368,7 +403,7 @@

[ - ] Removed Files (1)

-

[ * ] Modified Files (8)

+

[ * ] Modified Files (9)

@@ -429,6 +464,13 @@

[ * ] Modified Files (8)

+ + + + + + + + + + + + + + + + + + + + + +
ILMismatch dotnet-ildasm (version: 0.12.2)
+
+ #3 Show assembly semantic changes +
+
@@ -463,6 +505,13 @@

[ * ] Modified Files (8)

ILMismatch dotnet-ildasm (version: 0.12.2)
+
+ #5 Show assembly semantic changes +
+
@@ -513,6 +562,30 @@

[ * ] Modified Files (8)

#8 Inline diff skipped: diff too large (12500 diff lines; limit is 10000). Increase InlineDiffMaxDiffLines in config to enable.

9util/Legacy.dll[2026-03-15 08:50:00 → 2026-03-15 09:01:00]ILMismatchdotnet-ildasm (version: 0.12.2)
+
+ #9 Show assembly semantic changes +
+
+
+ #9 Show IL diff (+1 / -1) +
+

Summary

@@ -522,8 +595,8 @@

Summary

Unchanged5 Added1 Removed1 - Modified8 - Compared17 (Old) vs 17 (New) + Modified9 + Compared18 (Old) vs 18 (New)

IL Cache Stats

@@ -630,6 +703,7 @@

[ ! ] Modified Files — Timestamps Regressed (2) setupLazyDiff(); initColResize(); syncTableWidths(); + syncScTableWidths(); }); function collectState() { @@ -654,7 +728,7 @@

[ ! ] Modified Files — Timestamps Regressed (2) var openDetails = Array.from(document.querySelectorAll('details[open]')); openDetails.forEach(function(d){ d.removeAttribute('open'); }); // 2. Capture current effective column widths to bake into reviewed HTML as defaults - var colVarNames = ['--col-reason-w','--col-notes-w','--col-path-w','--col-diff-w','--col-disasm-w']; + var colVarNames = ['--col-reason-w','--col-notes-w','--col-path-w','--col-diff-w','--col-disasm-w','--sc-class-w','--sc-basetype-w','--sc-type-w','--sc-name-w','--sc-rettype-w','--sc-params-w','--sc-body-w','--sc-cnt-class-w']; var cs = getComputedStyle(root); var curWidths = {}; colVarNames.forEach(function(v){ curWidths[v] = (root.style.getPropertyValue(v) || cs.getPropertyValue(v)).trim(); }); @@ -673,7 +747,15 @@

[ ! ] Modified Files — Timestamps Regressed (2) + '; --col-notes-w: ' + curWidths['--col-notes-w'] + '; --col-path-w: ' + curWidths['--col-path-w'] + '; --col-diff-w: ' + curWidths['--col-diff-w'] - + '; --col-disasm-w: ' + curWidths['--col-disasm-w'] + '; }'); + + '; --col-disasm-w: ' + curWidths['--col-disasm-w'] + + '; --sc-class-w: ' + curWidths['--sc-class-w'] + + '; --sc-basetype-w: ' + curWidths['--sc-basetype-w'] + + '; --sc-type-w: ' + curWidths['--sc-type-w'] + + '; --sc-name-w: ' + curWidths['--sc-name-w'] + + '; --sc-rettype-w: ' + curWidths['--sc-rettype-w'] + + '; --sc-params-w: ' + curWidths['--sc-params-w'] + + '; --sc-body-w: ' + curWidths['--sc-body-w'] + + '; --sc-cnt-class-w: ' + curWidths['--sc-cnt-class-w'] + '; }'); // Remove inline col-var overrides from element (now baked into :root) html = html.replace(/(]*?) style="[^"]*"/, '$1'); // Replace controls bar with reviewed banner @@ -695,7 +777,7 @@

[ ! ] Modified Files — Timestamps Regressed (2) document.querySelectorAll('input[type="text"], textarea').forEach(function(inp){ inp.value=''; }); // Reset column widths to defaults var root = document.documentElement; - ['--col-reason-w','--col-notes-w','--col-path-w','--col-diff-w','--col-disasm-w'].forEach(function(v){ root.style.removeProperty(v); }); + ['--col-reason-w','--col-notes-w','--col-path-w','--col-diff-w','--col-disasm-w','--sc-class-w','--sc-basetype-w','--sc-type-w','--sc-name-w','--sc-rettype-w','--sc-params-w','--sc-body-w','--sc-cnt-class-w'].forEach(function(v){ root.style.removeProperty(v); }); syncTableWidths(); // Close all open diff/IL-diff details document.querySelectorAll('details[open]').forEach(function(d){ d.removeAttribute('open'); }); @@ -715,7 +797,7 @@

[ ! ] Modified Files — Timestamps Regressed (2) + px('--col-reason-w', 10) + px('--col-notes-w', 10) + px('--col-path-w', 22) + px('--col-diff-w', 9) + px('--col-disasm-w', 28); - document.querySelectorAll('table:not(.stat-table):not(.diff-table)').forEach(function(t) { + document.querySelectorAll('table:not(.stat-table):not(.diff-table):not(.semantic-changes-table)').forEach(function(t) { t.style.width = w + 'px'; }); } @@ -736,41 +818,88 @@

[ ! ] Modified Files — Timestamps Regressed (2) if (!b64) return; d.removeAttribute('data-diff-html'); d.removeEventListener('toggle', onToggle); - try { d.insertAdjacentHTML('beforeend', decodeDiffHtml(b64)); } catch(e) {} + try { + d.insertAdjacentHTML('beforeend', decodeDiffHtml(b64)); + d.querySelectorAll('th.th-resizable').forEach(function(th) { + if (th.querySelector('.col-resize-handle')) return; + initColResizeSingle(th); + }); + syncScTableWidths(); + if (__savedState__ === null) { + d.querySelectorAll('input').forEach(function(el) { + el.addEventListener('change', autoSave); + }); + } + var toRestore = __savedState__ || JSON.parse(localStorage.getItem(__storageKey__) || 'null'); + if (toRestore) { + d.querySelectorAll('input[id]').forEach(function(el) { + if (el.id in toRestore) { + if (el.type === 'checkbox') el.checked = Boolean(toRestore[el.id]); + else el.value = String(toRestore[el.id] || ''); + } + }); + } + if (__savedState__ !== null) { + d.querySelectorAll('input[type="checkbox"]').forEach(function(cb){ cb.style.pointerEvents='none'; cb.style.cursor='default'; }); + } + } catch(e) {} }); }); } + function syncScTableWidths() { + var scEmPx = 12; + var root = document.documentElement; + var px = function(v, fb) { + var s = root.style.getPropertyValue(v) || getComputedStyle(root).getPropertyValue(v); + return (parseFloat(s) || fb) * scEmPx; + }; + var detW = 3.2 * scEmPx + + px('--sc-class-w', 14) + px('--sc-basetype-w', 16) + + 7 * scEmPx + 10 * scEmPx + 8 * scEmPx + 11 * scEmPx + + px('--sc-type-w', 12) + px('--sc-name-w', 10) + + px('--sc-rettype-w', 12) + px('--sc-params-w', 18) + + px('--sc-body-w', 5); + document.querySelectorAll('table.sc-detail').forEach(function(t) { t.style.width = detW + 'px'; }); + var cntW = px('--sc-cnt-class-w', 22) + 7 * scEmPx + 4 * scEmPx; + document.querySelectorAll('table.sc-count').forEach(function(t) { t.style.width = cntW + 'px'; }); + } + + function initColResizeSingle(th) { + var label = document.createElement('span'); + label.className = 'th-label'; + while (th.childNodes.length) label.appendChild(th.childNodes[0]); + th.appendChild(label); + var handle = document.createElement('div'); + handle.className = 'col-resize-handle'; + th.appendChild(handle); + var varName = th.dataset.colVar; + var isSc = !!th.closest('.semantic-changes-table'); + handle.addEventListener('mousedown', function(e) { + e.preventDefault(); + var startX = e.clientX; + var root = document.documentElement; + var emPx = isSc ? 12 : (parseFloat(getComputedStyle(root).fontSize) || 16); + var cur = root.style.getPropertyValue(varName) || getComputedStyle(root).getPropertyValue(varName); + var startPx = (parseFloat(cur) || 10) * emPx; + function onMove(ev) { + var newPx = Math.max(48, startPx + (ev.clientX - startX)); + root.style.setProperty(varName, (newPx / emPx).toFixed(2) + 'em'); + syncTableWidths(); + if (isSc) syncScTableWidths(); + } + function onUp() { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + } + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }); + } + function initColResize() { document.querySelectorAll('th.th-resizable').forEach(function(th) { - // Wrap text in a block span so overflow:hidden clips reliably at column boundary - var label = document.createElement('span'); - label.className = 'th-label'; - while (th.childNodes.length) label.appendChild(th.childNodes[0]); - th.appendChild(label); - var handle = document.createElement('div'); - handle.className = 'col-resize-handle'; - th.appendChild(handle); - var varName = th.dataset.colVar; - handle.addEventListener('mousedown', function(e) { - e.preventDefault(); - var startX = e.clientX; - var root = document.documentElement; - var emPx = parseFloat(getComputedStyle(root).fontSize) || 16; - var cur = root.style.getPropertyValue(varName) || getComputedStyle(root).getPropertyValue(varName); - var startPx = (parseFloat(cur) || 10) * emPx; - function onMove(ev) { - var newPx = Math.max(48, startPx + (ev.clientX - startX)); - root.style.setProperty(varName, (newPx / emPx).toFixed(2) + 'em'); - syncTableWidths(); - } - function onUp() { - document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onUp); - } - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); - }); + initColResizeSingle(th); }); } diff --git a/doc/samples/diff_report.md b/doc/samples/diff_report.md index 60768cf2..5bce02c3 100644 --- a/doc/samples/diff_report.md +++ b/doc/samples/diff_report.md @@ -33,7 +33,7 @@ ## [ - ] Removed Files (1) - [ - ] /Users/UserA/workspace/old/legacy/old-tool.txt [2026-03-15 08:55:00] -## [ * ] Modified Files (8) +## [ * ] Modified Files (9) - [ * ] config/app.config [2026-03-15 08:56:00 → 2026-03-15 09:01:00] `TextMismatch` - [ * ] payload.bin [2026-03-15 08:59:00 → 2026-03-15 08:54:00] `MD5Mismatch` - [ * ] src/App.dll [2026-03-15 08:58:00 → 2026-03-15 09:02:00] `ILMismatch` `dotnet-ildasm (version: 0.12.2)` @@ -42,14 +42,109 @@ - [ * ] src/Utils.cs [2026-03-15 08:57:00 → 2026-03-15 09:03:00] `TextMismatch` - [ * ] src/BigSchema.cs [2026-03-15 08:55:00 → 2026-03-15 09:04:00] `TextMismatch` - [ * ] src/LargeConfig.xml [2026-03-15 08:54:00 → 2026-03-15 09:05:00] `TextMismatch` +- [ * ] util/Legacy.dll [2026-03-15 08:50:00 → 2026-03-15 09:01:00] `ILMismatch` `dotnet-ildasm (version: 0.12.2)` ## Summary - Ignored : 3 - Unchanged : 5 - Added : 1 - Removed : 1 -- Modified : 8 -- Compared : 17 (Old) vs 17 (New) +- Modified : 9 +- Compared : 18 (Old) vs 18 (New) + +## Assembly Semantic Changes + +### src/App.dll + +| Class | BaseType | Change | Kind | Access | Modifiers | Type | Name | ReturnType | Parameters | Body | +|-------|----------|:------:|:----:|:------:|:---------:|------|------|------------|------------|------| +| MyApp.Controllers.ApiController | MyApp.Controllers.BaseController, System.IDisposable | `Added` | `Method` | `public` | | | HealthCheck | System.String | | | +| | | `Added` | `Method` | `public` | | | GetUsers | System.Collections.Generic.IList\ | System.Int32 page, System.Int32 pageSize = 20 | | +| | | `Removed` | `Method` | `public` | | | GetUsers | System.Collections.Generic.IList\ | System.Int32 page | | +| | | `Modified` | `Method` | `public` | `virtual` | | Search | System.Collections.Generic.IList\ | System.String query | `Changed` | +| | | `Modified` | `Method` | `protected` | | | OnAuthorize | System.Boolean | MyApp.Models.UserContext ctx | `Changed` | +| MyApp.Services.DataService | | `Added` | `Property` | `public` | | System.Int32 | CacheTimeout | | | | +| | | `Added` | `Property` | `public` | | MyApp.Models.CachePolicy | Policy | | | | +| | | `Added` | `Property` | `internal` | | MyApp.Services.IConnectionPool | ConnectionPool | | | | +| | | `Modified` | `Method` | `internal` | | | RefreshCache | System.Void | | `Changed` | +| | | `Modified` | `Method` | `private` | | | ValidateConnection | System.Boolean | System.String connStr | `Changed` | + +| Class | Change | Count | +|-------|:------:|------:| +| MyApp.Controllers.ApiController | `Added` | 2 | +| | `Removed` | 1 | +| | `Modified` | 2 | +| MyApp.Services.DataService | `Added` | 3 | +| | `Modified` | 2 | + +### src/Service.dll + +| Class | BaseType | Change | Kind | Access | Modifiers | Type | Name | ReturnType | Parameters | Body | +|-------|----------|:------:|:----:|:------:|:---------:|------|------|------------|------------|------| +| MyApp.Models.Coordinate | System.ValueType | `Added` | `Struct` | `public` | `readonly` | | | | | | +| | | `Added` | `Constructor` | `public` | | | Coordinate | System.Void | System.Double\u00A0X,\u00A0System.Double\u00A0Y | | +| | | `Added` | `Property` | `public` | | System.Double | X | | | | +| | | `Added` | `Property` | `public` | | System.Double | Y | | | | +| | | `Added` | `Method` | `public` | `override` | | ToString | System.String | | | +| MyApp.Models.OrderStatus | | `Added` | `Enum` | `public` | | | | | | | +| | | `Added` | `Field` | `public` | `static literal` | System.Int32 | Pending | | | | +| | | `Added` | `Field` | `public` | `static literal` | System.Int32 | Processing | | | | +| | | `Added` | `Field` | `public` | `static literal` | System.Int32 | Completed | | | | +| | | `Added` | `Field` | `public` | `static literal` | System.Int32 | Cancelled | | | | +| MyApp.Models.UserDto | | `Removed` | `Class` | `public` | | | | | | | +| | | `Removed` | `Property` | `public` | | System.String | Name | | | | +| | | `Removed` | `Property` | `public` | | System.Int32 | Age | | | | +| MyApp.Models.UserRecord | | `Added` | `Record` | `public` | | | | | | | +| | | `Added` | `Constructor` | `public` | | | UserRecord | System.Void | System.String Name, System.Int32 Age | | +| | | `Added` | `Property` | `public` | | System.String | Name | | | | +| | | `Added` | `Property` | `public` | | System.Int32 | Age | | | | +| | | `Added` | `Method` | `public` | `override` | | ToString | System.String | | | +| | | `Added` | `Method` | `public` | `virtual` | | Equals | System.Boolean | System.Object obj | | +| | | `Added` | `Method` | `public` | `override` | | GetHashCode | System.Int32 | | | +| MyApp.Services.BaseProcessor | | `Added` | `Class` | `public` | `abstract` | | | | | | +| | | `Added` | `Constructor` | `protected` | | | BaseProcessor | System.Void | | | +| | | `Added` | `Method` | `public` | `abstract` | | Execute | System.Threading.Tasks.Task | System.String input | | +| | | `Added` | `Method` | `protected` | `virtual` | | OnCompleted | System.Void | | | +| MyApp.Services.Constants | | `Added` | `Class` | `public` | `static` | | | | | | +| | | `Added` | `Field` | `public` | `static literal` | System.String | DefaultEndpoint | | | | +| | | `Added` | `Field` | `public` | `static literal` | System.Int32 | MaxRetries | | | | +| | | `Added` | `Field` | `public` | `static readonly` | System.TimeSpan | DefaultTimeout | | | | +| MyApp.Services.IValidator | | `Added` | `Interface` | `public` | | | | | | | +| | | `Added` | `Method` | `public` | | | Validate | System.Boolean | System.String input | | +| MyApp.Services.LegacyHelper | | `Removed` | `Class` | `internal` | | | | | | | +| | | `Removed` | `Method` | `public` | | | Convert | System.String | System.Object value | | +| | | `Removed` | `Method` | `public` | `static` | | Format | System.String | System.String template, System.Object[] args | | +| MyApp.Services.NewValidator | MyApp.Services.IValidator | `Added` | `Class` | `public` | `sealed` | | | | | | +| | | `Added` | `Constructor` | `public` | | | NewValidator | System.Void | | | +| | | `Added` | `Method` | `public` | | | Validate | System.Boolean | System.String input | | +| | | `Added` | `Method` | `public` | | | Validate | System.Boolean | System.String input, MyApp.Models.ValidationOptions options | | +| | | `Added` | `Method` | `private` | | | ParseInput | System.String | System.String raw | | +| | | `Added` | `Property` | `public` | | MyApp.Models.ValidationResult | LastResult | | | | +| | | `Added` | `Field` | `private` | `readonly` | System.String | _pattern | | | | +| MyApp.Services.OrderService | MyApp.Services.BaseService, MyApp.Services.IOrderProcessor | `Added` | `Method` | `public` | | | ValidateWithNewValidator | System.Boolean | System.String data | | +| | | `Added` | `Property` | `protected` | | MyApp.Models.OrderContext | CurrentContext | | | | +| | | `Added` | `Field` | `private` | `readonly` | MyApp.Models.UserRecord | _defaultUser | | | | +| | | `Removed` | `Method` | `public` | `virtual` | | LegacyValidate | System.Boolean | System.String data | | +| | | `Modified` | `Method` | `public` | | | ProcessOrder | System.Void | System.Int32 orderId | `Changed` | +| | | `Modified` | `Method` | `internal` | `static` | | CalculateTotal | System.Decimal | System.Int32 qty, System.Int32 price | `Changed` | + +| Class | Change | Count | +|-------|:------:|------:| +| MyApp.Models.Coordinate | `Added` | 5 | +| MyApp.Models.OrderStatus | `Added` | 5 | +| MyApp.Models.UserDto | `Removed` | 3 | +| MyApp.Models.UserRecord | `Added` | 7 | +| MyApp.Services.BaseProcessor | `Added` | 4 | +| MyApp.Services.Constants | `Added` | 4 | +| MyApp.Services.IValidator | `Added` | 2 | +| MyApp.Services.LegacyHelper | `Removed` | 3 | +| MyApp.Services.NewValidator | `Added` | 7 | +| MyApp.Services.OrderService | `Added` | 3 | +| | `Removed` | 1 | +| | `Modified` | 2 | + +### util/Legacy.dll +- No structural changes detected. See IL diff for implementation-level differences. ## IL Cache Stats - Hits : 42