From 1742d181b90e349874ef229dc0cd8e327660faed Mon Sep 17 00:00:00 2001 From: Widthdom Date: Thu, 16 Apr 2026 07:58:19 +0900 Subject: [PATCH] Split oversized service files into focused partials --- CHANGELOG.md | 2 + ProgramRunner.cs | 47 --- Runner/ProgramRunner.Core.cs | 60 +++ .../HtmlReportGenerateService.DetailRows.cs | 175 +------- ...mlReportGenerateService.SemanticChanges.cs | 194 +++++++++ Services/ILOutputService.Comparison.cs | 395 ++++++++++++++++++ Services/ILOutputService.cs | 352 +--------------- Services/ReportGenerateService.Helpers.cs | 361 ++++++++++++++++ Services/ReportGenerateService.cs | 319 -------------- doc/DEVELOPER_GUIDE.md | 14 +- 10 files changed, 1033 insertions(+), 886 deletions(-) create mode 100644 Runner/ProgramRunner.Core.cs create mode 100644 Services/HtmlReport/HtmlReportGenerateService.SemanticChanges.cs create mode 100644 Services/ILOutputService.Comparison.cs create mode 100644 Services/ReportGenerateService.Helpers.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e55747d..ff003399 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **Multiple-spinner matcha fallback now uses a generic message that matches the actual trigger condition** — the easter-egg text no longer mentions only beer-and-coffee combinations even though the runtime still triggers on any multi-spinner selection. This keeps the CLI message aligned with the current `MultipleSpinnersDetected` behavior and hardens the regression test by pinning the exact text. Affected: `Runner/SpinnerThemes.cs`, `FolderDiffIL4DotNet.Tests/Runner/SpinnerThemesTests.cs`. Tests: `SpinnerThemesTests.cs` (1 updated). - **HTML diff reports now use a tighter fixed width for the Timestamp column** — the embedded report stylesheet and layout script now share a dedicated `--col-ts-w` variable so `Timestamp` keeps its fixed-width old/new range format without wasting horizontal space in both the live report and downloaded reviewed HTML. Updated the HTML report regression test and synchronized the manual sample HTML with the compact timestamp width. Affected: `Services/HtmlReport/diff_report.css`, `Services/HtmlReport/js/diff_report_layout.js`, `FolderDiffIL4DotNet.Tests/Services/HtmlReportGenerateServiceTests.InlineDiff.cs`, `doc/samples/diff_report.html`. Tests: `HtmlReportGenerateServiceTests.InlineDiff.cs` (1 new). - **Console progress bars now show a fixed-width ETA segment to the right of the percentage** — `ProgressReportService` now appends `ETA HH:mm (+00 h 12 m)` to both inline and redirected progress output, keeping the console width stable while spinner frames animate. ETA estimation is defensive: missing phase timing or invalid progress values fall back to a fixed-width placeholder, and long runs clamp to `99 h 59 m` instead of overflowing the layout. Overflow-prone extreme ETA inputs are now saturated before `TimeSpan.FromSeconds(...)` / minute rounding so the progress renderer still degrades to the documented `99 h 59 m` ceiling instead of throwing. Updated progress-service tests plus README and guide/testing docs to match the new console contract. Affected: `Services/ProgressReportService.cs`, `FolderDiffIL4DotNet.Tests/Services/ProgressReportServiceTests.cs`, `README.md`, `doc/DEVELOPER_GUIDE.md`, `doc/TESTING_GUIDE.md`. Tests: `ProgressReportServiceTests.cs` (7 new, 5 updated). +- **Oversized service files are now split into focused partials without changing runtime behavior** — `ProgramRunner` moved shared constants, injected services, and constructors into `Runner/ProgramRunner.Core.cs`; `ILOutputService` moved IL filtering/block-comparison/disassembler-label helpers into `Services/ILOutputService.Comparison.cs`; `ReportGenerateService` moved report-output and Markdown/disassembler helper logic into `Services/ReportGenerateService.Helpers.cs`; and `HtmlReportGenerateService` moved assembly semantic-change detail rows into `Services/HtmlReport/HtmlReportGenerateService.SemanticChanges.cs` while centralizing `
` row rendering for inline/semantic/dependency sections. Updated the developer guide partial-layout table to match the new structure. Affected: `ProgramRunner.cs`, `Runner/ProgramRunner.Core.cs`, `Services/ILOutputService.cs`, `Services/ILOutputService.Comparison.cs`, `Services/ReportGenerateService.cs`, `Services/ReportGenerateService.Helpers.cs`, `Services/HtmlReport/HtmlReportGenerateService.DetailRows.cs`, `Services/HtmlReport/HtmlReportGenerateService.SemanticChanges.cs`, `doc/DEVELOPER_GUIDE.md`. Tests: `dotnet test FolderDiffIL4DotNet.Tests/FolderDiffIL4DotNet.Tests.csproj --configuration Release --nologo` (1919 passed, 1 skipped). #### Documentation @@ -1443,6 +1444,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **複数スピナー指定時の抹茶フォールバック文言を、実際の発火条件に合う汎用メッセージへ変更** — 現行ランタイムは複数スピナー指定なら組み合わせを問わずイースターエッグ文言を出すため、beer と coffee だけを名指ししていた文言をやめ、現在の `MultipleSpinnersDetected` 挙動と一致する表現にしました。あわせて回帰テストを「matcha を含む」ではなく完全一致で固定しました。対象: `Runner/SpinnerThemes.cs`, `FolderDiffIL4DotNet.Tests/Runner/SpinnerThemesTests.cs`。テスト: `SpinnerThemesTests.cs`(更新 1 件)。 - **HTML diff レポートの Timestamp 列を固定長のまま詰めて横幅を削減** — 埋め込みレポート CSS とレイアウト用スクリプトで専用の `--col-ts-w` 変数を共有するようにし、`Timestamp` が旧/新の固定長フォーマットを保ったまま、実レポートとダウンロード済み reviewed HTML の両方で横幅を取りすぎないようにしました。あわせて HTML レポートの回帰テストを追加し、手動管理のサンプル HTML も同じ幅に同期しました。対象: `Services/HtmlReport/diff_report.css`, `Services/HtmlReport/js/diff_report_layout.js`, `FolderDiffIL4DotNet.Tests/Services/HtmlReportGenerateServiceTests.InlineDiff.cs`, `doc/samples/diff_report.html`。テスト: `HtmlReportGenerateServiceTests.InlineDiff.cs`(追加 1 件)。 - **コンソール進捗バーの右側に固定長 ETA セグメントを追加** — `ProgressReportService` はインライン進捗とリダイレクト進捗の両方に `ETA HH:mm (+00 h 12 m)` を付与するようになり、スピナーフレームが回転してもコンソール幅がぶれないようになりました。ETA 推定は防御的で、フェーズ計測が未開始のときや進捗率が不正なときは固定長 placeholder にフォールバックし、長時間実行は `99 h 59 m` にクランプして表示崩れを防ぎます。さらに、極端に大きい ETA 入力でも `TimeSpan.FromSeconds(...)` や分丸めのオーバーフローで落ちず、文書どおり `99 h 59 m` 上限へ飽和するようにしました。あわせて、進捗サービスのテストと README / developer guide / testing guide を新しいコンソール契約に同期しました。対象: `Services/ProgressReportService.cs`, `FolderDiffIL4DotNet.Tests/Services/ProgressReportServiceTests.cs`, `README.md`, `doc/DEVELOPER_GUIDE.md`, `doc/TESTING_GUIDE.md`。テスト: `ProgressReportServiceTests.cs`(追加 7 件、更新 5 件)。 +- **巨大化していたサービスファイルを、実行時挙動を変えずに責務ごとの partial へ分割** — `ProgramRunner` は共有定数・注入サービス・コンストラクターを `Runner/ProgramRunner.Core.cs` へ移し、`ILOutputService` は IL フィルタリング/ブロック比較/逆アセンブラ表示ラベル補助を `Services/ILOutputService.Comparison.cs` へ切り出し、`ReportGenerateService` はレポート出力補助と Markdown/逆アセンブラ描画補助を `Services/ReportGenerateService.Helpers.cs` へ移し、`HtmlReportGenerateService` はアセンブリセマンティック変更詳細行を `Services/HtmlReport/HtmlReportGenerateService.SemanticChanges.cs` へ分離しつつ、inline/semantic/dependency の各詳細セクションで使う `
` 行描画を共通化しました。あわせて、開発者ガイドの partial 構成表も新レイアウトに追従させました。対象: `ProgramRunner.cs`, `Runner/ProgramRunner.Core.cs`, `Services/ILOutputService.cs`, `Services/ILOutputService.Comparison.cs`, `Services/ReportGenerateService.cs`, `Services/ReportGenerateService.Helpers.cs`, `Services/HtmlReport/HtmlReportGenerateService.DetailRows.cs`, `Services/HtmlReport/HtmlReportGenerateService.SemanticChanges.cs`, `doc/DEVELOPER_GUIDE.md`。テスト: `dotnet test FolderDiffIL4DotNet.Tests/FolderDiffIL4DotNet.Tests.csproj --configuration Release --nologo`(1919 passed, 1 skipped)。 #### ドキュメント diff --git a/ProgramRunner.cs b/ProgramRunner.cs index 7e9a425b..fd684f09 100644 --- a/ProgramRunner.cs +++ b/ProgramRunner.cs @@ -1,5 +1,4 @@ using System; -using System.Diagnostics; using System.Collections.Generic; using System.IO; using System.Linq; @@ -20,52 +19,6 @@ namespace FolderDiffIL4DotNet /// public sealed partial class ProgramRunner { - private const string INITIALIZING_LOGGER = "Initializing logger..."; - private const string LOGGER_INITIALIZED = "Logger initialized."; - private const string VALIDATING_ARGS = "Validating command line arguments..."; - private const string LOG_ARGS_VALIDATION_COMPLETED = "Command line arguments validation completed."; - private const string LOG_APP_STARTING = "Starting " + Constants.APP_NAME + "..."; - private const string LOG_APP_FINISHED = Constants.APP_NAME + " finished without errors. See Reports folder for details."; - private const string PRESS_ANY_KEY = "Press any key to exit..."; - private const string ERROR_KEY_PROMPT = "An error occurred during key prompt."; - private const string WARNING_NEW_FILE_TIMESTAMP_OLDER_THAN_OLD = "One or more modified files in 'new' have older timestamps than the corresponding files in 'old'. See diff_report for details."; - private const string WARNING_IL_FILTER_STRINGS_TOO_SHORT = "One or more ILIgnoreLineContainingStrings entries are very short and may inadvertently exclude legitimate IL lines. See diff_report Warnings section for details."; - private const string TIP_PRINT_CONFIG = "Tip: Run with --print-config to display the effective configuration as JSON."; - private const string INFO_AUTO_GENERATED_REPORT_LABEL = "Report label was not specified. Using auto-generated label: "; - - private readonly ILoggerService _logger; - private readonly ConfigService _configService; - private readonly Action _openFolderAction; - - /// - /// Initializes a new instance of . - /// の新しいインスタンスを初期化します。 - /// - /// Logger for diagnostic output. / 診断出力用ロガー。 - /// Service for loading configuration files. / 設定ファイル読込サービス。 - public ProgramRunner(ILoggerService logger, ConfigService configService) - : this(logger, configService, static processStartInfo => Process.Start(processStartInfo)) - { - } - - /// - /// Initializes a new instance of with a replaceable folder-open action for tests. - /// テスト用に差し替え可能なフォルダ開放アクション付きで の新しいインスタンスを初期化します。 - /// - /// Logger for diagnostic output. / 診断出力用ロガー。 - /// Service for loading configuration files. / 設定ファイル読込サービス。 - /// Action used by `--open-*` commands to launch the folder. / `--open-*` コマンドでフォルダを起動するためのアクション。 - internal ProgramRunner(ILoggerService logger, ConfigService configService, Action openFolderAction) - { - ArgumentNullException.ThrowIfNull(logger); - ArgumentNullException.ThrowIfNull(configService); - ArgumentNullException.ThrowIfNull(openFolderAction); - - _logger = logger; - _configService = configService; - _openFolderAction = openFolderAction; - } - /// /// Executes the main application flow and returns the process exit code. /// アプリケーションのメインフローを実行し、終了コードを返します。 diff --git a/Runner/ProgramRunner.Core.cs b/Runner/ProgramRunner.Core.cs new file mode 100644 index 00000000..d3c7d9c0 --- /dev/null +++ b/Runner/ProgramRunner.Core.cs @@ -0,0 +1,60 @@ +using System; +using System.Diagnostics; +using FolderDiffIL4DotNet.Common; +using FolderDiffIL4DotNet.Services; + +namespace FolderDiffIL4DotNet +{ + /// + /// Shared constants, dependencies, and constructors for . + /// の共有定数・依存関係・コンストラクターをまとめた partial です。 + /// + public sealed partial class ProgramRunner + { + private const string INITIALIZING_LOGGER = "Initializing logger..."; + private const string LOGGER_INITIALIZED = "Logger initialized."; + private const string VALIDATING_ARGS = "Validating command line arguments..."; + private const string LOG_ARGS_VALIDATION_COMPLETED = "Command line arguments validation completed."; + private const string LOG_APP_STARTING = "Starting " + Constants.APP_NAME + "..."; + private const string LOG_APP_FINISHED = Constants.APP_NAME + " finished without errors. See Reports folder for details."; + private const string PRESS_ANY_KEY = "Press any key to exit..."; + private const string ERROR_KEY_PROMPT = "An error occurred during key prompt."; + private const string WARNING_NEW_FILE_TIMESTAMP_OLDER_THAN_OLD = "One or more modified files in 'new' have older timestamps than the corresponding files in 'old'. See diff_report for details."; + private const string WARNING_IL_FILTER_STRINGS_TOO_SHORT = "One or more ILIgnoreLineContainingStrings entries are very short and may inadvertently exclude legitimate IL lines. See diff_report Warnings section for details."; + private const string TIP_PRINT_CONFIG = "Tip: Run with --print-config to display the effective configuration as JSON."; + private const string INFO_AUTO_GENERATED_REPORT_LABEL = "Report label was not specified. Using auto-generated label: "; + + private readonly ILoggerService _logger; + private readonly ConfigService _configService; + private readonly Action _openFolderAction; + + /// + /// Initializes a new instance of . + /// の新しいインスタンスを初期化します。 + /// + /// Logger for diagnostic output. / 診断出力用ロガー。 + /// Service for loading configuration files. / 設定ファイル読込サービス。 + public ProgramRunner(ILoggerService logger, ConfigService configService) + : this(logger, configService, static processStartInfo => Process.Start(processStartInfo)) + { + } + + /// + /// Initializes a new instance of with a replaceable folder-open action for tests. + /// テスト用に差し替え可能なフォルダ開放アクション付きで の新しいインスタンスを初期化します。 + /// + /// Logger for diagnostic output. / 診断出力用ロガー。 + /// Service for loading configuration files. / 設定ファイル読込サービス。 + /// Action used by `--open-*` commands to launch the folder. / `--open-*` コマンドでフォルダを起動するためのアクション。 + internal ProgramRunner(ILoggerService logger, ConfigService configService, Action openFolderAction) + { + ArgumentNullException.ThrowIfNull(logger); + ArgumentNullException.ThrowIfNull(configService); + ArgumentNullException.ThrowIfNull(openFolderAction); + + _logger = logger; + _configService = configService; + _openFolderAction = openFolderAction; + } + } +} diff --git a/Services/HtmlReport/HtmlReportGenerateService.DetailRows.cs b/Services/HtmlReport/HtmlReportGenerateService.DetailRows.cs index d17f2626..2f77e220 100644 --- a/Services/HtmlReport/HtmlReportGenerateService.DetailRows.cs +++ b/Services/HtmlReport/HtmlReportGenerateService.DetailRows.cs @@ -88,25 +88,7 @@ private void AppendInlineDiffRow( string diffLabel = diffDetail == FileDiffResultLists.DiffDetailResult.ILMismatch ? "Show IL diff" : "Show diff"; string summary = $" #{recordNo} {HtmlEncode(diffLabel)} (+{addedCount} / -{removedCount})"; string diffViewHtml = BuildDiffViewHtml(diffLines); - - writer.WriteLine(""); - writer.WriteLine($" "); - if (config.InlineDiffLazyRender) - { - string b64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(diffViewHtml)); - writer.WriteLine($"
"); - writer.WriteLine(summary); - writer.WriteLine("
"); - } - else - { - writer.WriteLine($"
"); - writer.WriteLine(summary); - writer.Write(diffViewHtml); - writer.WriteLine("
"); - } - writer.WriteLine(" "); - writer.WriteLine(""); + WriteDetailRowWithOptionalLazyContent(writer, detailsId, summary, diffViewHtml, config.InlineDiffLazyRender); } /// @@ -182,116 +164,6 @@ private void AppendInlineDiffRow( } } - private void AppendAssemblySemanticChangesRow( - TextWriter writer, - int idx, - string assemblyPath, - AssemblySemanticChangesSummary summary, - IReadOnlyConfigSettings config, - string sectionPrefix = "mod") - { - int recordNo = idx + 1; - var contentBuilder = new StringBuilder(); - contentBuilder.AppendLine("
"); - - if (summary.Entries.Count > 0) - { - contentBuilder.AppendLine($"

{HtmlEncode("Note: The semantic summary is supplementary information. Always verify the final details in the inline IL diff below.")}

"); - } - - if (summary.Entries.Count > 0) - { - 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($" "); - contentBuilder.AppendLine(""); - contentBuilder.AppendLine(""); - string prevType = ""; - int scRowIdx = 0; - foreach (var e in summary.EntriesByImportance) - { - bool isCont = e.TypeName == prevType; - string classTd = !isCont ? HtmlEncode(e.TypeName) : ""; - string baseTypeTd = !isCont ? HtmlEncode(e.BaseType) : ""; - prevType = e.TypeName; - string scImpAttr = $" data-sc-importance=\"{ImportanceToMarker(e.Importance)}\""; - // Store typename/basetype on every row so JS can restore group headers after filtering - // フィルタ後にグループヘッダーを復元できるよう、全行に typename/basetype を格納 - string scGroupAttrs = $" data-sc-typename=\"{HtmlEncode(e.TypeName)}\" data-sc-basetype=\"{HtmlEncode(e.BaseType)}\""; - string trOpen = isCont ? $"" : $""; - string accessTd = CodeWrapArrow(e.Access); - string modifiersTd = CodeWrapArrow(e.Modifiers); - string bodyTd = e.Body.Length > 0 ? $"{HtmlEncode(e.Body)}" : ""; - string cbId = $"sc_{sectionPrefix}_{idx}_{scRowIdx}"; - string changeMarker = ChangeToMarker(e.Change); - string statusCls = ChangeToStatusClass(e.Change); - string statusAttr = statusCls.Length > 0 ? $" class=\"{statusCls}\"" : ""; - string impMarker = ImportanceToMarker(e.Importance); - string impCls = ImportanceToClass(e.Importance); - string impAttr = impCls.Length > 0 ? $" class=\"{impCls}\"" : ""; - contentBuilder.AppendLine($"{trOpen}{changeMarker}{impMarker}"); - scRowIdx++; - } - contentBuilder.AppendLine("
{HtmlEncode("Class")}{HtmlEncode("BaseType")}{HtmlEncode("Status")}{HtmlEncode("Importance")}{HtmlEncode("Kind")}{HtmlEncode("Access")}{HtmlEncode("Modifiers")}{HtmlEncode("Type")}{HtmlEncode("Name")}{HtmlEncode("ReturnType")}{HtmlEncode("Parameters")}{HtmlEncode("Body")}
{classTd}{baseTypeTd}{HtmlEncode(e.MemberKind)}{accessTd}{modifiersTd}{HtmlEncode(e.MemberType)}{HtmlEncode(e.MemberName)}{HtmlEncode(e.ReturnType)}{HtmlEncode(e.Parameters)}{bodyTd}
"); - } - else - { - contentBuilder.AppendLine($"

{HtmlEncode("No structural changes detected. See IL diff for implementation-level differences.")}

"); - } - - contentBuilder.AppendLine("
"); - - string detailsId = $"semantic_{sectionPrefix}_{idx}"; - string highSuffix = summary.HighImportanceCount > 0 - ? $" ({summary.HighImportanceCount} High)" - : ""; - string deltaSuffix = BuildChangeDeltaSuffix(summary); - string summaryLabel = $" #{recordNo} {HtmlEncode("Show assembly semantic changes")}{highSuffix}{deltaSuffix}"; - string contentHtml = contentBuilder.ToString(); - - writer.WriteLine(""); - writer.WriteLine(" "); - if (config.InlineDiffLazyRender) - { - string b64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(contentHtml)); - writer.WriteLine($"
"); - writer.WriteLine(summaryLabel); - writer.WriteLine("
"); - } - else - { - writer.WriteLine($"
"); - writer.WriteLine(summaryLabel); - writer.Write(contentHtml); - writer.WriteLine("
"); - } - writer.WriteLine(" "); - writer.WriteLine(""); - } - private void AppendDependencyChangesRow( TextWriter writer, int idx, @@ -367,10 +239,19 @@ private void AppendDependencyChangesRow( string vulnSuffix = BuildVulnerabilitySummarySuffix(summary); string summaryLabel = $" #{recordNo} {HtmlEncode("Show dependency changes")}{highSuffix}{vulnSuffix}"; string contentHtml = contentBuilder.ToString(); + WriteDetailRowWithOptionalLazyContent(writer, detailsId, summaryLabel, contentHtml, config.InlineDiffLazyRender); + } + private static void WriteDetailRowWithOptionalLazyContent( + TextWriter writer, + string detailsId, + string summaryLabel, + string contentHtml, + bool shouldLazyRender) + { writer.WriteLine(""); writer.WriteLine(" "); - if (config.InlineDiffLazyRender) + if (shouldLazyRender) { string b64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(contentHtml)); writer.WriteLine($"
"); @@ -384,6 +265,7 @@ private void AppendDependencyChangesRow( writer.Write(contentHtml); writer.WriteLine("
"); } + writer.WriteLine(" "); writer.WriteLine(""); } @@ -411,12 +293,6 @@ private static string BuildReferencingAssembliesCell(IReadOnlyList? refs return sb.ToString(); } - private static string ChangeToMarker(string change) - => change switch { "Added" => "[ + ]", "Removed" => "[ - ]", "Modified" => "[ * ]", _ => change }; - - private static string ChangeToStatusClass(string change) - => change switch { "Added" => "sc-status-added", "Removed" => "sc-status-removed", "Modified" => "sc-status-modified", _ => "" }; - /// /// Builds the HTML content for a vulnerability cell in the dependency changes table. /// Shows advisory links with severity badges for new-version vulns (red) and resolved old-version vulns (green strikethrough). @@ -536,33 +412,6 @@ internal static bool IsAllowedUriScheme(string url) return false; } - /// - /// Builds the change delta suffix for the semantic changes summary label. - /// Returns HTML like " (+2 methods, -1 type)". - /// セマンティック変更サマリーラベルの変更差分サフィックスを構築します。 - /// - private static string BuildChangeDeltaSuffix(AssemblySemanticChangesSummary summary) - { - var parts = summary.GetChangeDeltaParts(); - if (parts.Count == 0) return ""; - - var sb = new StringBuilder(); - sb.Append(" ("); - for (int i = 0; i < parts.Count; i++) - { - if (i > 0) sb.Append(", "); - var (prefix, count, kindLabel) = parts[i]; - string cssClass = prefix switch { "+" => "color-added", "-" => "color-removed", _ => "" }; - string text = $"{prefix}{count} {kindLabel}"; - if (cssClass.Length > 0) - sb.Append($"{HtmlEncode(text)}"); - else - sb.Append(HtmlEncode(text)); - } - sb.Append(')'); - return sb.ToString(); - } - // Vulnerability style constants / 脆弱性スタイル定数 private const string NEW_VULN_CLASS = "vuln-new"; private const string RESOLVED_VULN_CLASS = "vuln-resolved"; diff --git a/Services/HtmlReport/HtmlReportGenerateService.SemanticChanges.cs b/Services/HtmlReport/HtmlReportGenerateService.SemanticChanges.cs new file mode 100644 index 00000000..7125218f --- /dev/null +++ b/Services/HtmlReport/HtmlReportGenerateService.SemanticChanges.cs @@ -0,0 +1,194 @@ +using System.IO; +using System.Text; +using FolderDiffIL4DotNet.Models; + +namespace FolderDiffIL4DotNet.Services +{ + /// + /// Assembly-semantic-change detail row builders for . + /// のアセンブリセマンティック変更詳細行ビルダーです。 + /// + public sealed partial class HtmlReportGenerateService + { + private void AppendAssemblySemanticChangesRow( + TextWriter writer, + int idx, + string assemblyPath, + AssemblySemanticChangesSummary summary, + IReadOnlyConfigSettings config, + string sectionPrefix = "mod") + { + int recordNo = idx + 1; + string detailsId = $"semantic_{sectionPrefix}_{idx}"; + string highSuffix = summary.HighImportanceCount > 0 + ? $" ({summary.HighImportanceCount} High)" + : string.Empty; + string deltaSuffix = BuildChangeDeltaSuffix(summary); + string summaryLabel = $" #{recordNo} {HtmlEncode("Show assembly semantic changes")}{highSuffix}{deltaSuffix}"; + string contentHtml = BuildAssemblySemanticChangesContentHtml(summary, sectionPrefix, idx); + WriteDetailRowWithOptionalLazyContent(writer, detailsId, summaryLabel, contentHtml, config.InlineDiffLazyRender); + } + + private string BuildAssemblySemanticChangesContentHtml( + AssemblySemanticChangesSummary summary, + string sectionPrefix, + int idx) + { + var contentBuilder = new StringBuilder(); + contentBuilder.AppendLine("
"); + if (summary.Entries.Count > 0) + { + contentBuilder.AppendLine($"

{HtmlEncode("Note: The semantic summary is supplementary information. Always verify the final details in the inline IL diff below.")}

"); + AppendAssemblySemanticChangesTable(contentBuilder, summary, sectionPrefix, idx); + } + else + { + contentBuilder.AppendLine($"

{HtmlEncode("No structural changes detected. See IL diff for implementation-level differences.")}

"); + } + + contentBuilder.AppendLine("
"); + return contentBuilder.ToString(); + } + + private void AppendAssemblySemanticChangesTable( + StringBuilder contentBuilder, + AssemblySemanticChangesSummary summary, + string sectionPrefix, + int idx) + { + 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(""); + AppendAssemblySemanticChangesTableHeader(contentBuilder); + AppendAssemblySemanticChangeRows(contentBuilder, summary, sectionPrefix, idx); + contentBuilder.AppendLine("
"); + } + + private void AppendAssemblySemanticChangesTableHeader(StringBuilder contentBuilder) + { + contentBuilder.AppendLine(""); + contentBuilder.AppendLine($" "); + contentBuilder.AppendLine($" {HtmlEncode("Class")}"); + contentBuilder.AppendLine($" {HtmlEncode("BaseType")}"); + contentBuilder.AppendLine($" {HtmlEncode("Status")}{HtmlEncode("Importance")}{HtmlEncode("Kind")}{HtmlEncode("Access")}{HtmlEncode("Modifiers")}"); + contentBuilder.AppendLine($" {HtmlEncode("Type")}"); + contentBuilder.AppendLine($" {HtmlEncode("Name")}"); + contentBuilder.AppendLine($" {HtmlEncode("ReturnType")}"); + contentBuilder.AppendLine($" {HtmlEncode("Parameters")}"); + contentBuilder.AppendLine($" {HtmlEncode("Body")}"); + contentBuilder.AppendLine(""); + contentBuilder.AppendLine(""); + } + + private void AppendAssemblySemanticChangeRows( + StringBuilder contentBuilder, + AssemblySemanticChangesSummary summary, + string sectionPrefix, + int idx) + { + string prevType = string.Empty; + int scRowIdx = 0; + foreach (var e in summary.EntriesByImportance) + { + bool isContinuation = e.TypeName == prevType; + prevType = e.TypeName; + string rowOpen = BuildAssemblySemanticRowOpen(e, isContinuation); + string rowHtml = BuildAssemblySemanticRowHtml(e, rowOpen, isContinuation, sectionPrefix, idx, scRowIdx); + contentBuilder.AppendLine(rowHtml); + scRowIdx++; + } + + contentBuilder.AppendLine(""); + } + + private string BuildAssemblySemanticRowOpen(MemberChangeEntry e, bool isContinuation) + { + string importanceAttr = $" data-sc-importance=\"{ImportanceToMarker(e.Importance)}\""; + string groupAttrs = $" data-sc-typename=\"{HtmlEncode(e.TypeName)}\" data-sc-basetype=\"{HtmlEncode(e.BaseType)}\""; + return isContinuation + ? $"" + : $""; + } + + private string BuildAssemblySemanticRowHtml( + MemberChangeEntry e, + string rowOpen, + bool isContinuation, + string sectionPrefix, + int idx, + int rowIndex) + { + string classTd = isContinuation ? string.Empty : HtmlEncode(e.TypeName); + string baseTypeTd = isContinuation ? string.Empty : HtmlEncode(e.BaseType); + string accessTd = CodeWrapArrow(e.Access); + string modifiersTd = CodeWrapArrow(e.Modifiers); + string bodyTd = e.Body.Length > 0 ? $"{HtmlEncode(e.Body)}" : string.Empty; + string cbId = $"sc_{sectionPrefix}_{idx}_{rowIndex}"; + string changeMarker = ChangeToMarker(e.Change); + string statusCls = ChangeToStatusClass(e.Change); + string statusAttr = statusCls.Length > 0 ? $" class=\"{statusCls}\"" : string.Empty; + string importanceMarker = ImportanceToMarker(e.Importance); + string importanceCls = ImportanceToClass(e.Importance); + string importanceAttr = importanceCls.Length > 0 ? $" class=\"{importanceCls}\"" : string.Empty; + return $"{rowOpen}{classTd}{baseTypeTd}{changeMarker}{importanceMarker}{HtmlEncode(e.MemberKind)}{accessTd}{modifiersTd}{HtmlEncode(e.MemberType)}{HtmlEncode(e.MemberName)}{HtmlEncode(e.ReturnType)}{HtmlEncode(e.Parameters)}{bodyTd}"; + } + + private static string ChangeToMarker(string change) + => change switch { "Added" => "[ + ]", "Removed" => "[ - ]", "Modified" => "[ * ]", _ => change }; + + private static string ChangeToStatusClass(string change) + => change switch { "Added" => "sc-status-added", "Removed" => "sc-status-removed", "Modified" => "sc-status-modified", _ => string.Empty }; + + /// + /// Builds the change delta suffix for the semantic changes summary label. + /// Returns HTML like " (+2 methods, -1 type)". + /// セマンティック変更サマリーラベルの変更差分サフィックスを構築します。 + /// + private static string BuildChangeDeltaSuffix(AssemblySemanticChangesSummary summary) + { + var parts = summary.GetChangeDeltaParts(); + if (parts.Count == 0) + { + return string.Empty; + } + + var sb = new StringBuilder(); + sb.Append(" ("); + for (int i = 0; i < parts.Count; i++) + { + if (i > 0) + { + sb.Append(", "); + } + + var (prefix, count, kindLabel) = parts[i]; + string cssClass = prefix switch { "+" => "color-added", "-" => "color-removed", _ => string.Empty }; + string text = $"{prefix}{count} {kindLabel}"; + if (cssClass.Length > 0) + { + sb.Append($"{HtmlEncode(text)}"); + } + else + { + sb.Append(HtmlEncode(text)); + } + } + + sb.Append(')'); + return sb.ToString(); + } + } +} diff --git a/Services/ILOutputService.Comparison.cs b/Services/ILOutputService.Comparison.cs new file mode 100644 index 00000000..9d440cad --- /dev/null +++ b/Services/ILOutputService.Comparison.cs @@ -0,0 +1,395 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using FolderDiffIL4DotNet.Common; +using FolderDiffIL4DotNet.Core.Diagnostics; +using FolderDiffIL4DotNet.Core.IL; +using FolderDiffIL4DotNet.Models; + +namespace FolderDiffIL4DotNet.Services +{ + /// + /// IL comparison, filtering, and disassembler-label helpers for . + /// の IL 比較・フィルタリング・逆アセンブラ表示補助をまとめた partial です。 + /// + public sealed partial class ILOutputService + { + /// + /// Compares two IL line collections after applying exclusion filters, without materializing + /// the filtered results into separate lists. Advances dual indices, skipping excluded lines, + /// and short-circuits on the first mismatch — O(1) extra memory beyond the input lists. + /// 2 つの IL 行コレクションを除外フィルタ適用後に比較します。フィルタ済みの別リストを + /// 実体化せずに 2 つのインデックスを進め、除外行をスキップしながら最初の不一致で即終了します。 + /// 入力リスト以外の追加メモリは O(1) です。 + /// + internal static bool StreamingFilteredSequenceEqual( + IReadOnlyList lines1, + IReadOnlyList lines2, + bool shouldIgnoreContainingStrings, + IReadOnlyCollection ilIgnoreContainingStrings, + bool shouldIgnoreMVID = true) + { + int i = 0; + int j = 0; + int count1 = lines1.Count; + int count2 = lines2.Count; + while (true) + { + while (i < count1 && ShouldExcludeIlLine(lines1[i], shouldIgnoreContainingStrings, ilIgnoreContainingStrings, shouldIgnoreMVID)) + { + i++; + } + + while (j < count2 && ShouldExcludeIlLine(lines2[j], shouldIgnoreContainingStrings, ilIgnoreContainingStrings, shouldIgnoreMVID)) + { + j++; + } + + bool end1 = i >= count1; + bool end2 = j >= count2; + if (end1 && end2) + { + return true; + } + + if (end1 || end2) + { + return false; + } + + // Compare with leading/trailing whitespace trimmed to absorb indentation + // variations between disassembler versions or formatting differences. + // 逆アセンブラバージョン間のインデント差異やフォーマット差異を吸収するため + // 先頭・末尾空白をトリムして比較する。 + if (!lines1[i].AsSpan().Trim().SequenceEqual(lines2[j].AsSpan().Trim())) + { + return false; + } + + i++; + j++; + } + } + + /// + /// Filters IL lines by applying exclusion rules, returning a new list of non-excluded lines. + /// Used when the filtered result must be materialized (e.g. for IL text file output). + /// IL 行を除外ルールでフィルタリングし、除外されなかった行の新しいリストを返します。 + /// フィルタ結果の実体化が必要な場合(IL テキストファイル出力等)に使用します。 + /// + internal static List FilterIlLines( + IReadOnlyList lines, + bool shouldIgnoreContainingStrings, + IReadOnlyCollection ilIgnoreContainingStrings, + bool shouldIgnoreMVID = true) + { + var result = new List(lines.Count); + for (int i = 0; i < lines.Count; i++) + { + if (!ShouldExcludeIlLine(lines[i], shouldIgnoreContainingStrings, ilIgnoreContainingStrings, shouldIgnoreMVID)) + { + result.Add(lines[i]); + } + } + + return result; + } + + /// + /// Splits IL text into lines and filters out excluded lines in a single pass, + /// avoiding intermediate list allocations from separate Split → Where → ToList chains. + /// IL テキストを行に分割し、除外行を 1 パスでフィルタリングすることで + /// Split → Where → ToList の中間リスト割り当てを回避します。 + /// + private static List SplitAndFilterIlLines( + string ilText, + bool shouldIgnoreContainingStrings, + IReadOnlyCollection ilIgnoreContainingStrings, + bool shouldIgnoreMVID = true) + { + var result = new List(); + int startIndex = 0; + int length = ilText.Length; + while (startIndex <= length) + { + int newlineIndex = ilText.IndexOf('\n', startIndex); + string line; + if (newlineIndex < 0) + { + line = ilText.Substring(startIndex); + startIndex = length + 1; + } + else + { + line = ilText.Substring(startIndex, newlineIndex - startIndex); + startIndex = newlineIndex + 1; + } + + if (!ShouldExcludeIlLine(line, shouldIgnoreContainingStrings, ilIgnoreContainingStrings, shouldIgnoreMVID)) + { + result.Add(line); + } + } + + return result; + } + + /// + /// Determines whether a line should be excluded from IL comparison. + /// IL 比較時に除外すべき行かを判定します。 + /// + private static bool ShouldExcludeIlLine( + string line, + bool shouldIgnoreContainingStrings, + IReadOnlyCollection ilIgnoreContainingStrings, + bool shouldIgnoreMVID = true) + { + if (line is null) + { + return false; + } + + if (shouldIgnoreMVID && line.StartsWith(Constants.IL_MVID_LINE_PREFIX, StringComparison.Ordinal)) + { + return true; + } + + if (!shouldIgnoreContainingStrings || ilIgnoreContainingStrings == null || ilIgnoreContainingStrings.Count == 0) + { + return false; + } + + return ilIgnoreContainingStrings.Any(target => line.Contains(target, StringComparison.Ordinal)); + } + + /// + /// Compares two filtered IL line lists using signature-aware, block-based (order-independent) comparison. + /// Parses IL into logical blocks (methods, classes, etc.) via , + /// extracts each block's signature (directive line) and content hash, then compares as multisets + /// of (signature, hash) pairs. This handles compiler-induced reordering while correctly detecting + /// content changes even when blocks with different signatures have identical bodies. + /// フィルタ済み IL 行リストをシグネチャ対応のブロック単位(順序非依存)で比較します。 + /// で IL を論理ブロック(メソッド、クラス等)に分割し、 + /// 各ブロックのシグネチャ(ディレクティブ行)とコンテンツハッシュを抽出してから + /// (シグネチャ, ハッシュ) ペアのマルチセットとして比較します。コンパイラによる並び替えを + /// 許容しつつ、異なるシグネチャのブロック間でのコンテンツ入れ替わりを正しく検知します。 + /// + internal static bool BlockAwareSequenceEqual(IReadOnlyList filteredLines1, IReadOnlyList filteredLines2) + { + var blocks1 = ILBlockParser.ParseBlocks(filteredLines1); + var blocks2 = ILBlockParser.ParseBlocks(filteredLines2); + if (blocks1.Count != blocks2.Count) + { + return false; + } + + var hashBag1 = BuildBlockHashBag(blocks1); + var hashBag2 = BuildBlockHashBag(blocks2); + if (hashBag1.Count != hashBag2.Count) + { + return false; + } + + foreach (var kvp in hashBag1) + { + if (!hashBag2.TryGetValue(kvp.Key, out int count2) || count2 != kvp.Value) + { + return false; + } + } + + return true; + } + + /// + /// Builds a multiset ((signature, hash) → count) from a list of IL blocks. + /// Each block's signature is extracted via , + /// ensuring that blocks are matched by both identity (signature) and content (hash). + /// IL ブロックのリストからマルチセット((シグネチャ, ハッシュ) → 出現回数)を構築します。 + /// 各ブロックのシグネチャは で抽出し、 + /// ブロックの同一性(シグネチャ)と内容(ハッシュ)の両方で照合します。 + /// + private static Dictionary<(string Signature, string Hash), int> BuildBlockHashBag(List> blocks) + { + var bag = new Dictionary<(string Signature, string Hash), int>(); + foreach (var block in blocks) + { + string signature = ILBlockParser.ExtractBlockSignature(block); + string hash = ComputeBlockHash(block); + var key = (signature, hash); + bag.TryGetValue(key, out int count); + bag[key] = count + 1; + } + + return bag; + } + + /// + /// Computes a SHA256 hash of an IL block's content (all lines joined with newline). + /// IL ブロックの内容(全行を改行で結合)の SHA256 ハッシュを計算します。 + /// + private static string ComputeBlockHash(List blockLines) + { + using var sha256 = SHA256.Create(); + var sb = new StringBuilder(); + for (int i = 0; i < blockLines.Count; i++) + { + if (i > 0) + { + sb.Append('\n'); + } + + // Trim leading/trailing whitespace to absorb indentation variations + // 先頭・末尾空白をトリムしてインデント差異を吸収 + sb.Append(blockLines[i].Trim()); + } + + byte[] hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(sb.ToString())); + return BitConverter.ToString(hashBytes).Replace("-", string.Empty); + } + + /// + /// Validates configured IL filter strings and returns warning messages for any that appear + /// too short or overly broad. Strings shorter than + /// characters risk matching legitimate IL lines. + /// 設定された IL フィルタ文字列を検証し、短すぎるまたは広範すぎるパターンに対する + /// 警告メッセージを返します。 文字未満の文字列は + /// 正規の IL 行を誤って除外するリスクがあります。 + /// + /// The normalized filter strings as returned by . / が返した正規化済みフィルタ文字列。 + /// List of warning messages (empty if all strings are safe). / 警告メッセージのリスト(すべて安全なら空)。 + internal static List ValidateILFilterStrings(IReadOnlyCollection normalizedStrings) + { + var warnings = new List(); + if (normalizedStrings == null || normalizedStrings.Count == 0) + { + return warnings; + } + + foreach (var s in normalizedStrings) + { + if (s.Length < IL_FILTER_STRING_MIN_LENGTH) + { + warnings.Add($"ILIgnoreLineContainingStrings: \"{s}\" is very short ({s.Length} chars) and may inadvertently exclude legitimate IL lines. Consider using a more specific pattern."); + } + } + + return warnings; + } + + /// + /// Normalises the strings used for contains-based line exclusion during IL comparison (removes null/whitespace, trims, deduplicates). + /// IL 比較時に「含む」判定で除外対象とする文字列を正規化します(null/空白除外、trim、重複排除)。 + /// + private static List GetNormalizedIlIgnoreContainingStrings(IReadOnlyConfigSettings config) + { + if (config?.ILIgnoreLineContainingStrings == null) + { + return new List(); + } + + return config.ILIgnoreLineContainingStrings + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value.Trim()) + .Distinct(StringComparer.Ordinal) + .ToList(); + } + + /// + /// Merges the disassembler labels used for old/new into a single comparison label. + /// old/new で使用された逆アセンブラ表示ラベルを比較用に 1 つへまとめます。 + /// + private static string? BuildComparisonDisassemblerLabel(string commandStringOld, string commandStringNew) + { + var oldLabel = BuildToolAndVersionLabel(commandStringOld); + var newLabel = BuildToolAndVersionLabel(commandStringNew); + if (string.IsNullOrWhiteSpace(oldLabel)) + { + return newLabel; + } + + if (string.IsNullOrWhiteSpace(newLabel)) + { + return oldLabel; + } + + if (string.Equals(oldLabel, newLabel, StringComparison.OrdinalIgnoreCase)) + { + return oldLabel; + } + + throw new InvalidOperationException($"IL comparison requires the same disassembler and version for old/new. old: '{oldLabel}', new: '{newLabel}'."); + } + + /// + /// Extracts a "toolName (version: x.y.z)" label from a command string. + /// 実行コマンド文字列から「ツール名 (version: x.y.z)」形式を抽出します。 + /// + private static string? BuildToolAndVersionLabel(string commandString) + { + if (string.IsNullOrWhiteSpace(commandString)) + { + return null; + } + + var tokens = ProcessHelper.TokenizeCommand(commandString); + if (tokens.Count == 0) + { + return null; + } + + string toolName; + if (string.Equals(tokens[0], Constants.DOTNET_MUXER, StringComparison.OrdinalIgnoreCase) && + tokens.Count >= 2 && + (string.Equals(tokens[1], Constants.ILDASM_LABEL, StringComparison.OrdinalIgnoreCase) || + string.Equals(tokens[1], Constants.DOTNET_ILDASM, StringComparison.OrdinalIgnoreCase))) + { + toolName = Constants.DOTNET_ILDASM; + } + else + { + toolName = Path.GetFileName(tokens[0]); + } + + if (string.IsNullOrWhiteSpace(toolName)) + { + return null; + } + + if (string.Equals(toolName, Constants.DOTNET_MUXER, StringComparison.OrdinalIgnoreCase)) + { + toolName = Constants.DOTNET_ILDASM; + } + + int versionStart = commandString.IndexOf(VERSION_LABEL_PREFIX, StringComparison.Ordinal); + if (versionStart < 0) + { + return toolName; + } + + int versionEnd = commandString.IndexOf(')', versionStart + VERSION_LABEL_PREFIX.Length); + if (versionEnd <= versionStart) + { + return toolName; + } + + string version = commandString.Substring( + versionStart + VERSION_LABEL_PREFIX.Length, + versionEnd - (versionStart + VERSION_LABEL_PREFIX.Length)).Trim(); + if (string.IsNullOrWhiteSpace(version)) + { + return toolName; + } + + if (string.Equals(toolName, Constants.ILDASM_LABEL, StringComparison.OrdinalIgnoreCase)) + { + return $"{Constants.ILDASM_LABEL} (version: {version})"; + } + + return $"{toolName} (version: {version})"; + } + } +} diff --git a/Services/ILOutputService.cs b/Services/ILOutputService.cs index bdbdb415..d25f1672 100644 --- a/Services/ILOutputService.cs +++ b/Services/ILOutputService.cs @@ -2,8 +2,6 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using System.Security.Cryptography; -using System.Text; using System.Threading; using System.Threading.Tasks; using FolderDiffIL4DotNet.Common; @@ -19,7 +17,7 @@ namespace FolderDiffIL4DotNet.Services /// Facade coordinating disassembly (), cache control (), and output delegation. /// 逆アセンブル()・キャッシュ制御()と出力サービスへの委譲を担うファサード。 ///
- public sealed class ILOutputService : IILOutputService + public sealed partial class ILOutputService : IILOutputService { private const string LOG_OPTIMIZE_FOR_NETWORK_SHARES_SKIP = $"OptimizeForNetworkShares=true: Skip {Constants.LABEL_IL} precompute/prefetch to reduce network I/O."; private const string VERSION_LABEL_PREFIX = " (version: "; @@ -192,353 +190,5 @@ public async Task PrecomputeAsync(IEnumerable filesAbsolutePaths, int ma return (areEqual, disassemblerLabel); } - /// - /// Compares two IL line collections after applying exclusion filters, without materializing - /// the filtered results into separate lists. Advances dual indices, skipping excluded lines, - /// and short-circuits on the first mismatch — O(1) extra memory beyond the input lists. - /// 2 つの IL 行コレクションを除外フィルタ適用後に比較します。フィルタ済みの別リストを - /// 実体化せずに 2 つのインデックスを進め、除外行をスキップしながら最初の不一致で即終了します。 - /// 入力リスト以外の追加メモリは O(1) です。 - /// - internal static bool StreamingFilteredSequenceEqual( - IReadOnlyList lines1, - IReadOnlyList lines2, - bool shouldIgnoreContainingStrings, - IReadOnlyCollection ilIgnoreContainingStrings, - bool shouldIgnoreMVID = true) - { - int i = 0, j = 0; - int count1 = lines1.Count, count2 = lines2.Count; - while (true) - { - // Advance past excluded lines / 除外行をスキップ - while (i < count1 && ShouldExcludeIlLine(lines1[i], shouldIgnoreContainingStrings, ilIgnoreContainingStrings, shouldIgnoreMVID)) - { - i++; - } - while (j < count2 && ShouldExcludeIlLine(lines2[j], shouldIgnoreContainingStrings, ilIgnoreContainingStrings, shouldIgnoreMVID)) - { - j++; - } - - bool end1 = i >= count1; - bool end2 = j >= count2; - - if (end1 && end2) - { - return true; - } - if (end1 || end2) - { - return false; - } - // Compare with leading/trailing whitespace trimmed to absorb indentation - // variations between disassembler versions or formatting differences. - // 逆アセンブラバージョン間のインデント差異やフォーマット差異を吸収するため - // 先頭・末尾空白をトリムして比較する。 - if (!lines1[i].AsSpan().Trim().SequenceEqual(lines2[j].AsSpan().Trim())) - { - return false; - } - i++; - j++; - } - } - - /// - /// Filters IL lines by applying exclusion rules, returning a new list of non-excluded lines. - /// Used when the filtered result must be materialized (e.g. for IL text file output). - /// IL 行を除外ルールでフィルタリングし、除外されなかった行の新しいリストを返します。 - /// フィルタ結果の実体化が必要な場合(IL テキストファイル出力等)に使用します。 - /// - internal static List FilterIlLines( - IReadOnlyList lines, - bool shouldIgnoreContainingStrings, - IReadOnlyCollection ilIgnoreContainingStrings, - bool shouldIgnoreMVID = true) - { - var result = new List(lines.Count); - for (int i = 0; i < lines.Count; i++) - { - if (!ShouldExcludeIlLine(lines[i], shouldIgnoreContainingStrings, ilIgnoreContainingStrings, shouldIgnoreMVID)) - { - result.Add(lines[i]); - } - } - return result; - } - - /// - /// Splits IL text into lines and filters out excluded lines in a single pass, - /// avoiding intermediate list allocations from separate Split → Where → ToList chains. - /// IL テキストを行に分割し、除外行を 1 パスでフィルタリングすることで - /// Split → Where → ToList の中間リスト割り当てを回避します。 - /// - private static List SplitAndFilterIlLines(string ilText, bool shouldIgnoreContainingStrings, IReadOnlyCollection ilIgnoreContainingStrings, bool shouldIgnoreMVID = true) - { - var result = new List(); - int startIndex = 0; - int length = ilText.Length; - while (startIndex <= length) - { - int newlineIndex = ilText.IndexOf('\n', startIndex); - string line; - if (newlineIndex < 0) - { - line = ilText.Substring(startIndex); - startIndex = length + 1; - } - else - { - line = ilText.Substring(startIndex, newlineIndex - startIndex); - startIndex = newlineIndex + 1; - } - if (!ShouldExcludeIlLine(line, shouldIgnoreContainingStrings, ilIgnoreContainingStrings, shouldIgnoreMVID)) - { - result.Add(line); - } - } - return result; - } - - /// - /// Determines whether a line should be excluded from IL comparison. - /// IL 比較時に除外すべき行かを判定します。 - /// - private static bool ShouldExcludeIlLine(string line, bool shouldIgnoreContainingStrings, IReadOnlyCollection ilIgnoreContainingStrings, - bool shouldIgnoreMVID = true) - { - if (line is null) - { - return false; - } - - if (shouldIgnoreMVID && line.StartsWith(Constants.IL_MVID_LINE_PREFIX, StringComparison.Ordinal)) - { - return true; - } - - if (!shouldIgnoreContainingStrings || ilIgnoreContainingStrings == null || ilIgnoreContainingStrings.Count == 0) - { - return false; - } - - return ilIgnoreContainingStrings.Any(target => line.Contains(target, StringComparison.Ordinal)); - } - - /// - /// Compares two filtered IL line lists using signature-aware, block-based (order-independent) comparison. - /// Parses IL into logical blocks (methods, classes, etc.) via , - /// extracts each block's signature (directive line) and content hash, then compares as multisets - /// of (signature, hash) pairs. This handles compiler-induced reordering while correctly detecting - /// content changes even when blocks with different signatures have identical bodies. - /// フィルタ済み IL 行リストをシグネチャ対応のブロック単位(順序非依存)で比較します。 - /// で IL を論理ブロック(メソッド、クラス等)に分割し、 - /// 各ブロックのシグネチャ(ディレクティブ行)とコンテンツハッシュを抽出してから - /// (シグネチャ, ハッシュ) ペアのマルチセットとして比較します。コンパイラによる並び替えを - /// 許容しつつ、異なるシグネチャのブロック間でのコンテンツ入れ替わりを正しく検知します。 - /// - internal static bool BlockAwareSequenceEqual(IReadOnlyList filteredLines1, IReadOnlyList filteredLines2) - { - var blocks1 = ILBlockParser.ParseBlocks(filteredLines1); - var blocks2 = ILBlockParser.ParseBlocks(filteredLines2); - - if (blocks1.Count != blocks2.Count) - { - return false; - } - - // Build multiset of (signature, content hash) pairs for each side and compare - // 各側の (シグネチャ, コンテンツハッシュ) ペアのマルチセットを構築して比較 - var hashBag1 = BuildBlockHashBag(blocks1); - var hashBag2 = BuildBlockHashBag(blocks2); - - if (hashBag1.Count != hashBag2.Count) - { - return false; - } - - foreach (var kvp in hashBag1) - { - if (!hashBag2.TryGetValue(kvp.Key, out int count2) || count2 != kvp.Value) - { - return false; - } - } - - return true; - } - - /// - /// Builds a multiset ((signature, hash) → count) from a list of IL blocks. - /// Each block's signature is extracted via , - /// ensuring that blocks are matched by both identity (signature) and content (hash). - /// IL ブロックのリストからマルチセット((シグネチャ, ハッシュ) → 出現回数)を構築します。 - /// 各ブロックのシグネチャは で抽出し、 - /// ブロックの同一性(シグネチャ)と内容(ハッシュ)の両方で照合します。 - /// - private static Dictionary<(string Signature, string Hash), int> BuildBlockHashBag(List> blocks) - { - var bag = new Dictionary<(string Signature, string Hash), int>(); - foreach (var block in blocks) - { - string signature = ILBlockParser.ExtractBlockSignature(block); - string hash = ComputeBlockHash(block); - var key = (signature, hash); - bag.TryGetValue(key, out int count); - bag[key] = count + 1; - } - return bag; - } - - /// - /// Computes a SHA256 hash of an IL block's content (all lines joined with newline). - /// IL ブロックの内容(全行を改行で結合)の SHA256 ハッシュを計算します。 - /// - private static string ComputeBlockHash(List blockLines) - { - using var sha256 = SHA256.Create(); - var sb = new StringBuilder(); - for (int i = 0; i < blockLines.Count; i++) - { - if (i > 0) sb.Append('\n'); - // Trim leading/trailing whitespace to absorb indentation variations - // 先頭・末尾空白をトリムしてインデント差異を吸収 - sb.Append(blockLines[i].Trim()); - } - byte[] hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(sb.ToString())); - return BitConverter.ToString(hashBytes).Replace("-", string.Empty); - } - - /// - /// Validates configured IL filter strings and returns warning messages for any that appear - /// too short or overly broad. Strings shorter than - /// characters risk matching legitimate IL lines. - /// 設定された IL フィルタ文字列を検証し、短すぎるまたは広範すぎるパターンに対する - /// 警告メッセージを返します。 文字未満の文字列は - /// 正規の IL 行を誤って除外するリスクがあります。 - /// - /// The normalized filter strings as returned by . / が返した正規化済みフィルタ文字列。 - /// List of warning messages (empty if all strings are safe). / 警告メッセージのリスト(すべて安全なら空)。 - internal static List ValidateILFilterStrings(IReadOnlyCollection normalizedStrings) - { - var warnings = new List(); - if (normalizedStrings == null || normalizedStrings.Count == 0) - return warnings; - - foreach (var s in normalizedStrings) - { - if (s.Length < IL_FILTER_STRING_MIN_LENGTH) - { - warnings.Add($"ILIgnoreLineContainingStrings: \"{s}\" is very short ({s.Length} chars) and may inadvertently exclude legitimate IL lines. Consider using a more specific pattern."); - } - } - return warnings; - } - - /// - /// Normalises the strings used for contains-based line exclusion during IL comparison (removes null/whitespace, trims, deduplicates). - /// IL 比較時に「含む」判定で除外対象とする文字列を正規化します(null/空白除外、trim、重複排除)。 - /// - private static List GetNormalizedIlIgnoreContainingStrings(IReadOnlyConfigSettings config) - { - if (config?.ILIgnoreLineContainingStrings == null) - { - return new List(); - } - - return config.ILIgnoreLineContainingStrings - .Where(value => !string.IsNullOrWhiteSpace(value)) - .Select(value => value.Trim()) - .Distinct(StringComparer.Ordinal) - .ToList(); - } - - /// - /// Merges the disassembler labels used for old/new into a single comparison label. - /// old/new で使用された逆アセンブラ表示ラベルを比較用に 1 つへまとめます。 - /// - private static string? BuildComparisonDisassemblerLabel(string commandStringOld, string commandStringNew) - { - var oldLabel = BuildToolAndVersionLabel(commandStringOld); - var newLabel = BuildToolAndVersionLabel(commandStringNew); - if (string.IsNullOrWhiteSpace(oldLabel)) - { - return newLabel; - } - if (string.IsNullOrWhiteSpace(newLabel)) - { - return oldLabel; - } - if (string.Equals(oldLabel, newLabel, StringComparison.OrdinalIgnoreCase)) - { - return oldLabel; - } - throw new InvalidOperationException($"IL comparison requires the same disassembler and version for old/new. old: '{oldLabel}', new: '{newLabel}'."); - } - - /// - /// Extracts a "toolName (version: x.y.z)" label from a command string. - /// 実行コマンド文字列から「ツール名 (version: x.y.z)」形式を抽出します。 - /// - private static string? BuildToolAndVersionLabel(string commandString) - { - if (string.IsNullOrWhiteSpace(commandString)) - { - return null; - } - - var tokens = ProcessHelper.TokenizeCommand(commandString); - if (tokens.Count == 0) - { - return null; - } - - string toolName; - if (string.Equals(tokens[0], Constants.DOTNET_MUXER, StringComparison.OrdinalIgnoreCase) && - tokens.Count >= 2 && - (string.Equals(tokens[1], Constants.ILDASM_LABEL, StringComparison.OrdinalIgnoreCase) || - string.Equals(tokens[1], Constants.DOTNET_ILDASM, StringComparison.OrdinalIgnoreCase))) - { - toolName = Constants.DOTNET_ILDASM; - } - else - { - toolName = Path.GetFileName(tokens[0]); - } - - if (string.IsNullOrWhiteSpace(toolName)) - { - return null; - } - if (string.Equals(toolName, Constants.DOTNET_MUXER, StringComparison.OrdinalIgnoreCase)) - { - toolName = Constants.DOTNET_ILDASM; - } - - var versionStart = commandString.IndexOf(VERSION_LABEL_PREFIX, StringComparison.Ordinal); - if (versionStart < 0) - { - return toolName; - } - - var versionEnd = commandString.IndexOf(')', versionStart + VERSION_LABEL_PREFIX.Length); - if (versionEnd <= versionStart) - { - return toolName; - } - - var version = commandString.Substring(versionStart + VERSION_LABEL_PREFIX.Length, versionEnd - (versionStart + VERSION_LABEL_PREFIX.Length)).Trim(); - if (string.IsNullOrWhiteSpace(version)) - { - return toolName; - } - - if (string.Equals(toolName, Constants.ILDASM_LABEL, StringComparison.OrdinalIgnoreCase)) - { - return $"{Constants.ILDASM_LABEL} (version: {version})"; - } - return $"{toolName} (version: {version})"; - } } } diff --git a/Services/ReportGenerateService.Helpers.cs b/Services/ReportGenerateService.Helpers.cs new file mode 100644 index 00000000..4dcca4bd --- /dev/null +++ b/Services/ReportGenerateService.Helpers.cs @@ -0,0 +1,361 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using FolderDiffIL4DotNet.Common; +using FolderDiffIL4DotNet.Core.Common; +using FolderDiffIL4DotNet.Core.IO; +using FolderDiffIL4DotNet.Models; +using FolderDiffIL4DotNet.Services.Caching; + +namespace FolderDiffIL4DotNet.Services +{ + /// + /// Helper methods used by and its section writers. + /// と各セクションライターで共有する補助メソッド群です。 + /// + public sealed partial class ReportGenerateService + { + private void LogReportOutputFailure(string diffReportAbsolutePath, Exception exception) + => _logger.LogMessage(AppLogLevel.Error, + $"Failed to output report to '{diffReportAbsolutePath}' ({exception.GetType().Name}): {exception.Message}", + shouldOutputMessageToConsole: true, + exception); + + private static void PrepareOutputPathForOverwrite(string outputFileAbsolutePath) + { + if (!File.Exists(outputFileAbsolutePath)) + { + return; + } + + var attributes = File.GetAttributes(outputFileAbsolutePath); + if ((attributes & FileAttributes.ReadOnly) != 0) + { + File.SetAttributes(outputFileAbsolutePath, attributes & ~FileAttributes.ReadOnly); + } + + File.Delete(outputFileAbsolutePath); + } + + private void TrySetReportReadOnly(string diffReportAbsolutePath) + { + try + { + FileSystemUtility.TrySetReadOnly(diffReportAbsolutePath); + } + catch (Exception ex) when (ExceptionFilters.IsPathOrFileIoRecoverable(ex)) + { + LogReportProtectionWarning(diffReportAbsolutePath, ex); + } + } + + private void LogReportProtectionWarning(string diffReportAbsolutePath, Exception ex) + { + _logger.LogMessage(AppLogLevel.Warning, + $"Failed to mark report as read-only: '{diffReportAbsolutePath}' ({ex.GetType().Name}): {ex.Message}", + shouldOutputMessageToConsole: true, + ex); + } + + private static string GetIgnoredFileLocationLabel(FileDiffResultLists.IgnoredFileLocation location) + => location switch + { + FileDiffResultLists.IgnoredFileLocation.Old => REPORT_LOCATION_OLD, + FileDiffResultLists.IgnoredFileLocation.New => REPORT_LOCATION_NEW, + FileDiffResultLists.IgnoredFileLocation.Old | FileDiffResultLists.IgnoredFileLocation.New => REPORT_LOCATION_BOTH, + _ => string.Empty + }; + + private static string? BuildIgnoredFileTimestampInfo( + KeyValuePair entry, + string oldFolderAbsolutePath, + string newFolderAbsolutePath) + { + bool hasOld = (entry.Value & FileDiffResultLists.IgnoredFileLocation.Old) != 0; + bool hasNew = (entry.Value & FileDiffResultLists.IgnoredFileLocation.New) != 0; + if (!hasOld && !hasNew) + { + return null; + } + + if (hasOld && hasNew) + { + string oldTs = TimestampCache.GetOrAdd(Path.Combine(oldFolderAbsolutePath, entry.Key)); + string newTs = TimestampCache.GetOrAdd(Path.Combine(newFolderAbsolutePath, entry.Key)); + return $"{oldTs}{REPORT_TIMESTAMP_ARROW}{newTs}"; + } + + return hasOld + ? TimestampCache.GetOrAdd(Path.Combine(oldFolderAbsolutePath, entry.Key)) + : TimestampCache.GetOrAdd(Path.Combine(newFolderAbsolutePath, entry.Key)); + } + + private static string BuildDisassemblerHeaderText(FileDiffResultLists fileDiffResultLists) + { + var observedLabels = fileDiffResultLists.DisassemblerToolVersions.Keys + .Concat(fileDiffResultLists.DisassemblerToolVersionsFromCache.Keys) + .Where(label => !string.IsNullOrWhiteSpace(label)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(GetDisassemblerDisplayOrder) + .ThenByDescending(label => label.IndexOf("(version:", StringComparison.OrdinalIgnoreCase) >= 0) + .ThenBy(label => label, StringComparer.OrdinalIgnoreCase) + .ToList(); + + return observedLabels.Count == 0 + ? REPORT_DISASSEMBLER_NOT_USED + : string.Join(REPORT_LIST_SEPARATOR, observedLabels); + } + + private static int GetDisassemblerDisplayOrder(string label) + { + string toolName = ExtractToolName(label); + if (string.Equals(toolName, Constants.DOTNET_ILDASM, StringComparison.OrdinalIgnoreCase)) + { + return 0; + } + + if (string.Equals(toolName, Constants.ILDASM_LABEL, StringComparison.OrdinalIgnoreCase)) + { + return 1; + } + + if (string.Equals(toolName, Constants.ILSPY_CMD, StringComparison.OrdinalIgnoreCase)) + { + return 2; + } + + return 3; + } + + private static string ExtractToolName(string label) + { + if (string.IsNullOrWhiteSpace(label)) + { + return string.Empty; + } + + int versionIndex = label.IndexOf(" (version:", StringComparison.OrdinalIgnoreCase); + return versionIndex >= 0 ? label.Substring(0, versionIndex).Trim() : label.Trim(); + } + + private static string BuildDiffDetailDisplay( + string fileRelativePath, + FileDiffResultLists.DiffDetailResult diffDetail, + FileDiffResultLists fileDiffResultLists) + { + ChangeImportance? importance = fileDiffResultLists.GetMaxImportance(fileRelativePath); + return importance == null + ? $"`{diffDetail}`" + : $"`{diffDetail}` `{importance.Value}`"; + } + + /// + /// Builds a Markdown vulnerability column value for a dependency change entry. + /// 依存関係変更エントリの Markdown 脆弱性カラム値を構築します。 + /// + private static string BuildMarkdownVulnColumn(VulnerabilityCheckResult? vuln) + { + if (vuln == null || !vuln.HasAnyVulnerabilities) + { + return "—"; + } + + var parts = new List(); + foreach (var v in vuln.NewVersionVulnerabilities) + { + string sev = VulnerabilityCheckResult.SeverityToLabel(v.Severity); + parts.Add($"⚠ {sev}"); + } + + if (vuln.HasResolvedVulnerabilities) + { + foreach (var v in vuln.OldVersionVulnerabilities) + { + bool alsoNew = vuln.NewVersionVulnerabilities.Any( + nv => string.Equals(nv.AdvisoryUrl, v.AdvisoryUrl, StringComparison.Ordinal)); + if (alsoNew) + { + continue; + } + + string sev = VulnerabilityCheckResult.SeverityToLabel(v.Severity); + parts.Add($"~~{sev}~~"); + } + } + + return parts.Count > 0 ? string.Join(", ", parts) : "—"; + } + + /// + /// Builds a Markdown referencing assemblies column value for a dependency change entry. + /// 依存関係変更エントリの Markdown 参照アセンブリカラム値を構築します。 + /// + private static string BuildMarkdownRefsColumn(IReadOnlyList? refs) + => refs is { Count: > 0 } ? string.Join(", ", refs) : "—"; + + private static string BuildDisassemblerDisplay( + string fileRelativePath, + FileDiffResultLists.DiffDetailResult diffDetail, + FileDiffResultLists fileDiffResultLists) + { + if ((diffDetail == FileDiffResultLists.DiffDetailResult.ILMatch || diffDetail == FileDiffResultLists.DiffDetailResult.ILMismatch) && + fileDiffResultLists.FileRelativePathToIlDisassemblerLabelDictionary.TryGetValue(fileRelativePath, out string? label) && + !string.IsNullOrWhiteSpace(label)) + { + return $"`{label}`"; + } + + return string.Empty; + } + + /// + /// Returns the .NET SDK / target framework display string for a file, or empty if not available. + /// ファイルの .NET SDK / ターゲットフレームワーク表示文字列を返します。利用不可の場合は空文字。 + /// + private static string BuildSdkVersionDisplay(string fileRelativePath, FileDiffResultLists fileDiffResultLists) + { + if (!fileDiffResultLists.FileRelativePathToSdkVersionDictionary.TryGetValue(fileRelativePath, out string? sdkVersion) || + string.IsNullOrWhiteSpace(sdkVersion)) + { + return string.Empty; + } + + int arrowIdx = sdkVersion.IndexOf(" → ", StringComparison.Ordinal); + if (arrowIdx >= 0) + { + string oldPart = sdkVersion.Substring(0, arrowIdx); + string newPart = sdkVersion.Substring(arrowIdx + " → ".Length); + return $"`{oldPart}` → `{newPart}`"; + } + + return $"`{sdkVersion}`"; + } + + /// + /// Returns the display order for Unchanged files: SHA256Match → ILMatch → TextMatch. + /// Unchanged ファイルの表示順序を返します: SHA256Match → ILMatch → TextMatch。 + /// + private static int GetUnchangedSortOrder(FileDiffResultLists.DiffDetailResult detail) + => detail switch + { + FileDiffResultLists.DiffDetailResult.SHA256Match => 0, + FileDiffResultLists.DiffDetailResult.ILMatch => 1, + FileDiffResultLists.DiffDetailResult.TextMatch => 2, + _ => 3 + }; + + /// + /// Returns the display order for Modified files: TextMismatch → ILMismatch → SHA256Mismatch. + /// Modified ファイルの表示順序を返します: TextMismatch → ILMismatch → SHA256Mismatch。 + /// + private static int GetModifiedSortOrder(FileDiffResultLists.DiffDetailResult detail) + => detail switch + { + FileDiffResultLists.DiffDetailResult.TextMismatch => 0, + FileDiffResultLists.DiffDetailResult.ILMismatch => 1, + FileDiffResultLists.DiffDetailResult.SHA256Mismatch => 2, + _ => 3 + }; + + /// + /// Returns a sort ordinal for (High=0 first). + /// のソート序数を返します(High=0 が先頭)。 + /// + private static int GetImportanceSortOrder(ChangeImportance? importance) + => importance switch + { + ChangeImportance.High => 0, + ChangeImportance.Medium => 1, + ChangeImportance.Low => 2, + _ => 3 + }; + + private static string BuildChangeTagDisplay(string fileRelativePath, FileDiffResultLists fileDiffResultLists) + { + if (fileDiffResultLists.FileRelativePathToChangeTags.TryGetValue(fileRelativePath, out var tags) && tags.Count > 0) + { + // Wrap each tag label individually in backticks / 各タグラベルを個別にバッククォートで囲む + return string.Join(", ", tags.Select(t => $"`{ChangeTagClassifier.GetLabel(t)}`")); + } + + return string.Empty; + } + + private static List GetNormalizedIlIgnoreContainingStrings(IReadOnlyConfigSettings config) + { + if (config?.ILIgnoreLineContainingStrings == null) + { + return new List(); + } + + return config.ILIgnoreLineContainingStrings + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value.Trim()) + .Distinct(StringComparer.Ordinal) + .ToList(); + } + + /// + /// Writes the Disassembler Availability table to the Markdown report header. + /// Markdown レポートヘッダに逆アセンブラ利用可否テーブルを書き込みます。 + /// + private static void WriteDisassemblerAvailabilityTable( + StreamWriter writer, + IReadOnlyList? probeResults, + string inUseHeaderText) + { + if (probeResults == null || probeResults.Count == 0) + { + return; + } + + writer.WriteLine("### Disassembler Availability"); + writer.WriteLine(); + writer.WriteLine("| Tool | Available | Version | In Use |"); + writer.WriteLine("|------|:---------:|---------|:------:|"); + foreach (var probe in probeResults) + { + bool isInUse = !string.IsNullOrWhiteSpace(inUseHeaderText) && + inUseHeaderText.IndexOf(probe.ToolName, StringComparison.OrdinalIgnoreCase) >= 0; + string available = probe.Available ? "Yes" : "No"; + string version = probe.Available && !string.IsNullOrWhiteSpace(probe.Version) + ? probe.Version + : REPORT_DISASSEMBLER_NOT_USED; + string inUseCol = isInUse ? "Yes" : "No"; + writer.WriteLine($"| {probe.ToolName} | {available} | {version} | {inUseCol} |"); + } + } + + /// + /// Writes warning banners for disassembler issues: no disassembler available, or mixed tool usage. + /// 逆アセンブラの問題に関する警告バナーを出力: 逆アセンブラ未検出、または複数ツール混在使用。 + /// + private static void WriteDisassemblerWarnings(StreamWriter writer, FileDiffResultLists fileDiffResultLists) + { + var probeResults = fileDiffResultLists.DisassemblerAvailability; + if (probeResults != null && probeResults.Count > 0 && !probeResults.Any(p => p.Available)) + { + writer.WriteLine(); + writer.WriteLine("> **⚠ Warning**: No disassembler tool is available. .NET assembly comparison will fail if any .dll/.exe files with differing SHA256 hashes are detected. Install `dotnet-ildasm` or `ilspycmd` to enable IL-level comparison."); + writer.WriteLine(); + } + + var allLabels = fileDiffResultLists.DisassemblerToolVersions.Keys + .Concat(fileDiffResultLists.DisassemblerToolVersionsFromCache.Keys) + .Where(label => !string.IsNullOrWhiteSpace(label)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + var distinctToolNames = allLabels + .Select(ExtractToolName) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + if (distinctToolNames.Count > 1) + { + writer.WriteLine(); + writer.WriteLine($"> **⚠ Warning**: Multiple disassembler tools were used across file comparisons in this run ({string.Join(", ", allLabels)}). Each file pair is compared using the same tool, but IL output format may differ between tools, reducing cross-file consistency. This typically occurs when the preferred tool fails on certain assemblies and the fallback is used. If caused by stale cache entries from a previous tool, use --clear-cache to resolve."); + writer.WriteLine(); + } + } + } +} diff --git a/Services/ReportGenerateService.cs b/Services/ReportGenerateService.cs index 74f0c404..cde273cb 100644 --- a/Services/ReportGenerateService.cs +++ b/Services/ReportGenerateService.cs @@ -208,324 +208,5 @@ internal static IReadOnlyList CreateBuiltInSectionWriters( new ILCacheStatsSectionWriter(), new WarningsSectionWriter(), }; - - private void LogReportOutputFailure(string diffReportAbsolutePath, Exception exception) - => _logger.LogMessage(AppLogLevel.Error, - $"Failed to output report to '{diffReportAbsolutePath}' ({exception.GetType().Name}): {exception.Message}", - shouldOutputMessageToConsole: true, - exception); - - private static void PrepareOutputPathForOverwrite(string outputFileAbsolutePath) - { - if (!File.Exists(outputFileAbsolutePath)) - { - return; - } - - var attributes = File.GetAttributes(outputFileAbsolutePath); - if ((attributes & FileAttributes.ReadOnly) != 0) - { - File.SetAttributes(outputFileAbsolutePath, attributes & ~FileAttributes.ReadOnly); - } - - File.Delete(outputFileAbsolutePath); - } - - private void TrySetReportReadOnly(string diffReportAbsolutePath) - { - try - { - FileSystemUtility.TrySetReadOnly(diffReportAbsolutePath); - } - catch (Exception ex) when (ExceptionFilters.IsPathOrFileIoRecoverable(ex)) - { - LogReportProtectionWarning(diffReportAbsolutePath, ex); - } - } - - private void LogReportProtectionWarning(string diffReportAbsolutePath, Exception ex) - { - _logger.LogMessage(AppLogLevel.Warning, - $"Failed to mark report as read-only: '{diffReportAbsolutePath}' ({ex.GetType().Name}): {ex.Message}", - shouldOutputMessageToConsole: true, - ex); - } - - // ── Private static helpers used by section writers ────────────────────── - - private static string GetIgnoredFileLocationLabel(FileDiffResultLists.IgnoredFileLocation location) - => location switch - { - FileDiffResultLists.IgnoredFileLocation.Old => REPORT_LOCATION_OLD, - FileDiffResultLists.IgnoredFileLocation.New => REPORT_LOCATION_NEW, - FileDiffResultLists.IgnoredFileLocation.Old | FileDiffResultLists.IgnoredFileLocation.New => REPORT_LOCATION_BOTH, - _ => string.Empty - }; - - private static string? BuildIgnoredFileTimestampInfo( - KeyValuePair entry, - string oldFolderAbsolutePath, - string newFolderAbsolutePath) - { - bool hasOld = (entry.Value & FileDiffResultLists.IgnoredFileLocation.Old) != 0; - bool hasNew = (entry.Value & FileDiffResultLists.IgnoredFileLocation.New) != 0; - if (!hasOld && !hasNew) - { - return null; - } - if (hasOld && hasNew) - { - var oldTs = Caching.TimestampCache.GetOrAdd(Path.Combine(oldFolderAbsolutePath, entry.Key)); - var newTs = Caching.TimestampCache.GetOrAdd(Path.Combine(newFolderAbsolutePath, entry.Key)); - return $"{oldTs}{REPORT_TIMESTAMP_ARROW}{newTs}"; - } - var ts = hasOld - ? Caching.TimestampCache.GetOrAdd(Path.Combine(oldFolderAbsolutePath, entry.Key)) - : Caching.TimestampCache.GetOrAdd(Path.Combine(newFolderAbsolutePath, entry.Key)); - return ts; - } - - private static string BuildDisassemblerHeaderText(FileDiffResultLists fileDiffResultLists) - { - var observedLabels = fileDiffResultLists.DisassemblerToolVersions.Keys - .Concat(fileDiffResultLists.DisassemblerToolVersionsFromCache.Keys) - .Where(label => !string.IsNullOrWhiteSpace(label)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(GetDisassemblerDisplayOrder) - .ThenByDescending(label => label.IndexOf("(version:", StringComparison.OrdinalIgnoreCase) >= 0) - .ThenBy(label => label, StringComparer.OrdinalIgnoreCase) - .ToList(); - - return observedLabels.Count == 0 - ? REPORT_DISASSEMBLER_NOT_USED - : string.Join(REPORT_LIST_SEPARATOR, observedLabels); - } - - private static int GetDisassemblerDisplayOrder(string label) - { - var toolName = ExtractToolName(label); - if (string.Equals(toolName, Constants.DOTNET_ILDASM, StringComparison.OrdinalIgnoreCase)) return 0; - if (string.Equals(toolName, Constants.ILDASM_LABEL, StringComparison.OrdinalIgnoreCase)) return 1; - if (string.Equals(toolName, Constants.ILSPY_CMD, StringComparison.OrdinalIgnoreCase)) return 2; - return 3; - } - - private static string ExtractToolName(string label) - { - if (string.IsNullOrWhiteSpace(label)) return string.Empty; - var versionIndex = label.IndexOf(" (version:", StringComparison.OrdinalIgnoreCase); - return versionIndex >= 0 ? label.Substring(0, versionIndex).Trim() : label.Trim(); - } - - private static string BuildDiffDetailDisplay(string fileRelativePath, FileDiffResultLists.DiffDetailResult diffDetail, FileDiffResultLists fileDiffResultLists) - { - var importance = fileDiffResultLists.GetMaxImportance(fileRelativePath); - if (importance == null) - return $"`{diffDetail}`"; - return $"`{diffDetail}` `{importance.Value}`"; - } - - /// - /// Builds a Markdown vulnerability column value for a dependency change entry. - /// 依存関係変更エントリの Markdown 脆弱性カラム値を構築します。 - /// - private static string BuildMarkdownVulnColumn(VulnerabilityCheckResult? vuln) - { - if (vuln == null || !vuln.HasAnyVulnerabilities) - return "—"; - - var parts = new System.Collections.Generic.List(); - - // New version vulnerabilities / 新バージョンの脆弱性 - foreach (var v in vuln.NewVersionVulnerabilities) - { - string sev = VulnerabilityCheckResult.SeverityToLabel(v.Severity); - parts.Add($"⚠ {sev}"); - } - - // Resolved vulnerabilities / 解消済み脆弱性 - if (vuln.HasResolvedVulnerabilities) - { - foreach (var v in vuln.OldVersionVulnerabilities) - { - bool alsoNew = false; - foreach (var nv in vuln.NewVersionVulnerabilities) - if (string.Equals(nv.AdvisoryUrl, v.AdvisoryUrl, System.StringComparison.Ordinal)) { alsoNew = true; break; } - if (alsoNew) continue; - string sev = VulnerabilityCheckResult.SeverityToLabel(v.Severity); - parts.Add($"~~{sev}~~"); - } - } - - return parts.Count > 0 ? string.Join(", ", parts) : "—"; - } - - /// - /// Builds a Markdown referencing assemblies column value for a dependency change entry. - /// 依存関係変更エントリの Markdown 参照アセンブリカラム値を構築します。 - /// - private static string BuildMarkdownRefsColumn(System.Collections.Generic.IReadOnlyList? refs) - { - if (refs is not { Count: > 0 }) - return "—"; - return string.Join(", ", refs); - } - - private static string BuildDisassemblerDisplay(string fileRelativePath, FileDiffResultLists.DiffDetailResult diffDetail, FileDiffResultLists fileDiffResultLists) - { - if ((diffDetail == FileDiffResultLists.DiffDetailResult.ILMatch || diffDetail == FileDiffResultLists.DiffDetailResult.ILMismatch) && - fileDiffResultLists.FileRelativePathToIlDisassemblerLabelDictionary.TryGetValue(fileRelativePath, out var label) && - !string.IsNullOrWhiteSpace(label)) - { - return $"`{label}`"; - } - return ""; - } - - /// - /// Returns the .NET SDK / target framework display string for a file, or empty if not available. - /// ファイルの .NET SDK / ターゲットフレームワーク表示文字列を返します。利用不可の場合は空文字。 - /// - private static string BuildSdkVersionDisplay(string fileRelativePath, FileDiffResultLists fileDiffResultLists) - { - if (fileDiffResultLists.FileRelativePathToSdkVersionDictionary.TryGetValue(fileRelativePath, out var sdkVersion) && - !string.IsNullOrWhiteSpace(sdkVersion)) - { - // If the value contains " → " (old ≠ new), backtick-wrap each part separately - // so the arrow is not emphasized: "`old` → `new`" - // " → " を含む場合(新旧異なる)、各パートを個別にバッククォートで囲み、 - // 矢印は強調しない: "`old` → `new`" - int arrowIdx = sdkVersion.IndexOf(" → ", StringComparison.Ordinal); - if (arrowIdx >= 0) - { - string oldPart = sdkVersion.Substring(0, arrowIdx); - string newPart = sdkVersion.Substring(arrowIdx + " → ".Length); - return $"`{oldPart}` → `{newPart}`"; - } - return $"`{sdkVersion}`"; - } - return ""; - } - - /// - /// Returns the display order for Unchanged files: SHA256Match → ILMatch → TextMatch. - /// Unchanged ファイルの表示順序を返します: SHA256Match → ILMatch → TextMatch。 - /// - private static int GetUnchangedSortOrder(FileDiffResultLists.DiffDetailResult detail) - => detail switch - { - FileDiffResultLists.DiffDetailResult.SHA256Match => 0, - FileDiffResultLists.DiffDetailResult.ILMatch => 1, - FileDiffResultLists.DiffDetailResult.TextMatch => 2, - _ => 3 - }; - - /// - /// Returns the display order for Modified files: TextMismatch → ILMismatch → SHA256Mismatch. - /// Modified ファイルの表示順序を返します: TextMismatch → ILMismatch → SHA256Mismatch。 - /// - private static int GetModifiedSortOrder(FileDiffResultLists.DiffDetailResult detail) - => detail switch - { - FileDiffResultLists.DiffDetailResult.TextMismatch => 0, - FileDiffResultLists.DiffDetailResult.ILMismatch => 1, - FileDiffResultLists.DiffDetailResult.SHA256Mismatch => 2, - _ => 3 - }; - - /// - /// Returns a sort ordinal for (High=0 first). - /// のソート序数を返します(High=0 が先頭)。 - /// - private static int GetImportanceSortOrder(ChangeImportance? importance) - => importance switch - { - ChangeImportance.High => 0, - ChangeImportance.Medium => 1, - ChangeImportance.Low => 2, - _ => 3 // null / no semantic changes - }; - - private static string BuildChangeTagDisplay(string fileRelativePath, FileDiffResultLists fileDiffResultLists) - { - if (fileDiffResultLists.FileRelativePathToChangeTags.TryGetValue(fileRelativePath, out var tags) && tags.Count > 0) - { - // Wrap each tag label individually in backticks / 各タグラベルを個別にバッククォートで囲む - return string.Join(", ", tags.Select(t => $"`{ChangeTagClassifier.GetLabel(t)}`")); - } - return ""; - } - - private static List GetNormalizedIlIgnoreContainingStrings(IReadOnlyConfigSettings config) - { - if (config?.ILIgnoreLineContainingStrings == null) return new List(); - return config.ILIgnoreLineContainingStrings - .Where(value => !string.IsNullOrWhiteSpace(value)) - .Select(value => value.Trim()) - .Distinct(StringComparer.Ordinal) - .ToList(); - } - - /// - /// Writes the Disassembler Availability table to the Markdown report header. - /// Markdown レポートヘッダに逆アセンブラ利用可否テーブルを書き込みます。 - /// - private static void WriteDisassemblerAvailabilityTable(StreamWriter writer, IReadOnlyList? probeResults, string inUseHeaderText) - { - if (probeResults == null || probeResults.Count == 0) - { - return; - } - writer.WriteLine("### Disassembler Availability"); - writer.WriteLine(); - writer.WriteLine("| Tool | Available | Version | In Use |"); - writer.WriteLine("|------|:---------:|---------|:------:|"); - foreach (var probe in probeResults) - { - // Check if this tool is the one actually used / このツールが実際に使用されたかチェック - bool isInUse = !string.IsNullOrWhiteSpace(inUseHeaderText) - && inUseHeaderText.IndexOf(probe.ToolName, StringComparison.OrdinalIgnoreCase) >= 0; - var available = probe.Available ? "Yes" : "No"; - var version = probe.Available && !string.IsNullOrWhiteSpace(probe.Version) - ? probe.Version - : REPORT_DISASSEMBLER_NOT_USED; - var inUseCol = isInUse ? "Yes" : "No"; - writer.WriteLine($"| {probe.ToolName} | {available} | {version} | {inUseCol} |"); - } - } - - /// - /// Writes warning banners for disassembler issues: no disassembler available, or mixed tool usage. - /// 逆アセンブラの問題に関する警告バナーを出力: 逆アセンブラ未検出、または複数ツール混在使用。 - /// - private static void WriteDisassemblerWarnings(StreamWriter writer, FileDiffResultLists fileDiffResultLists) - { - // Warning: no disassembler available / 警告: 逆アセンブラが利用不可 - var probeResults = fileDiffResultLists.DisassemblerAvailability; - if (probeResults != null && probeResults.Count > 0 && !probeResults.Any(p => p.Available)) - { - writer.WriteLine(); - writer.WriteLine("> **⚠ Warning**: No disassembler tool is available. .NET assembly comparison will fail if any .dll/.exe files with differing SHA256 hashes are detected. Install `dotnet-ildasm` or `ilspycmd` to enable IL-level comparison."); - writer.WriteLine(); - } - - // Warning: multiple different disassembler tools used / 警告: 異なる逆アセンブラツールが混在 - var allLabels = fileDiffResultLists.DisassemblerToolVersions.Keys - .Concat(fileDiffResultLists.DisassemblerToolVersionsFromCache.Keys) - .Where(label => !string.IsNullOrWhiteSpace(label)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - var distinctToolNames = allLabels - .Select(label => ExtractToolName(label)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - - if (distinctToolNames.Count > 1) - { - writer.WriteLine(); - writer.WriteLine($"> **⚠ Warning**: Multiple disassembler tools were used across file comparisons in this run ({string.Join(", ", allLabels)}). Each file pair is compared using the same tool, but IL output format may differ between tools, reducing cross-file consistency. This typically occurs when the preferred tool fails on certain assemblies and the fallback is used. If caused by stale cache entries from a previous tool, use --clear-cache to resolve."); - writer.WriteLine(); - } - } } } diff --git a/doc/DEVELOPER_GUIDE.md b/doc/DEVELOPER_GUIDE.md index e2685cf6..0c3e64be 100644 --- a/doc/DEVELOPER_GUIDE.md +++ b/doc/DEVELOPER_GUIDE.md @@ -76,17 +76,18 @@ Large service classes are split into partial class files to keep each file focus | Class | Main file | Partial files | | --- | --- | --- | -| `ProgramRunner` | [`ProgramRunner.cs`](../ProgramRunner.cs) | [`Runner/ProgramRunner.Types.cs`](../Runner/ProgramRunner.Types.cs) (nested types: `RunArguments`, `RunCompletionState`, `ProgramExitCode`, `ProgramRunResult`, `StepResult`), [`Runner/ProgramRunner.HelpText.cs`](../Runner/ProgramRunner.HelpText.cs) (CLI help message), [`Runner/ProgramRunner.Config.cs`](../Runner/ProgramRunner.Config.cs) (config loading, validation, CLI overrides), [`Runner/ProgramRunner.Wizard.cs`](../Runner/ProgramRunner.Wizard.cs) (interactive wizard mode), [`Runner/ProgramRunner.OpenFolder.cs`](../Runner/ProgramRunner.OpenFolder.cs) (folder-open early-exit commands) | +| `ProgramRunner` | [`ProgramRunner.cs`](../ProgramRunner.cs) | [`Runner/ProgramRunner.Core.cs`](../Runner/ProgramRunner.Core.cs) (shared constants, injected services, constructors), [`Runner/ProgramRunner.Types.cs`](../Runner/ProgramRunner.Types.cs) (nested types: `RunArguments`, `RunCompletionState`, `ProgramExitCode`, `ProgramRunResult`, `StepResult`), [`Runner/ProgramRunner.HelpText.cs`](../Runner/ProgramRunner.HelpText.cs) (CLI help message), [`Runner/ProgramRunner.Config.cs`](../Runner/ProgramRunner.Config.cs) (config loading, validation, CLI overrides), [`Runner/ProgramRunner.Wizard.cs`](../Runner/ProgramRunner.Wizard.cs) (interactive wizard mode), [`Runner/ProgramRunner.OpenFolder.cs`](../Runner/ProgramRunner.OpenFolder.cs) (folder-open early-exit commands) | | `ConfigSettings` | [`Models/ConfigSettings.cs`](../Models/ConfigSettings.cs) | [`Models/ConfigSettings.ReportSettings.cs`](../Models/ConfigSettings.ReportSettings.cs) (report output control), [`Models/ConfigSettings.ILSettings.cs`](../Models/ConfigSettings.ILSettings.cs) (IL comparison, cache, disassembler), [`Models/ConfigSettings.DiffSettings.cs`](../Models/ConfigSettings.DiffSettings.cs) (parallelism, network, inline diff), [`Models/ConfigSettings.PluginSettings.cs`](../Models/ConfigSettings.PluginSettings.cs) (plugin configuration) | | `ConfigSettingsBuilder` | [`Models/ConfigSettingsBuilder.cs`](../Models/ConfigSettingsBuilder.cs) | [`Models/ConfigSettingsBuilder.ReportSettings.cs`](../Models/ConfigSettingsBuilder.ReportSettings.cs) (report output control), [`Models/ConfigSettingsBuilder.ILSettings.cs`](../Models/ConfigSettingsBuilder.ILSettings.cs) (IL comparison, cache, disassembler), [`Models/ConfigSettingsBuilder.DiffSettings.cs`](../Models/ConfigSettingsBuilder.DiffSettings.cs) (parallelism, network, inline diff), [`Models/ConfigSettingsBuilder.PluginSettings.cs`](../Models/ConfigSettingsBuilder.PluginSettings.cs) (plugin configuration) | -| `HtmlReportGenerateService` | [`Services/HtmlReportGenerateService.cs`](../Services/HtmlReportGenerateService.cs) | [`Services/HtmlReport/HtmlReportGenerateService.Sections.cs`](../Services/HtmlReport/HtmlReportGenerateService.Sections.cs) (report section builders), [`…DetailRows.cs`](../Services/HtmlReport/HtmlReportGenerateService.DetailRows.cs) (inline diff, semantic changes, dependency changes detail rows), [`…Helpers.cs`](../Services/HtmlReport/HtmlReportGenerateService.Helpers.cs), [`…Css.cs`](../Services/HtmlReport/HtmlReportGenerateService.Css.cs) (loads [`diff_report.css`](../Services/HtmlReport/diff_report.css) embedded resource), [`…Js.cs`](../Services/HtmlReport/HtmlReportGenerateService.Js.cs) (loads and concatenates 13 JS modules from [`Services/HtmlReport/js/`](../Services/HtmlReport/js/) with `{{STORAGE_KEY}}`/`{{REPORT_DATE}}` placeholders) | +| `HtmlReportGenerateService` | [`Services/HtmlReportGenerateService.cs`](../Services/HtmlReportGenerateService.cs) | [`Services/HtmlReport/HtmlReportGenerateService.Sections.cs`](../Services/HtmlReport/HtmlReportGenerateService.Sections.cs) (report section builders), [`…DetailRows.cs`](../Services/HtmlReport/HtmlReportGenerateService.DetailRows.cs) (inline diff and dependency-change detail rows plus shared `
` rendering helper), [`…SemanticChanges.cs`](../Services/HtmlReport/HtmlReportGenerateService.SemanticChanges.cs) (assembly semantic change detail rows), [`…Helpers.cs`](../Services/HtmlReport/HtmlReportGenerateService.Helpers.cs), [`…Css.cs`](../Services/HtmlReport/HtmlReportGenerateService.Css.cs) (loads [`diff_report.css`](../Services/HtmlReport/diff_report.css) embedded resource), [`…Js.cs`](../Services/HtmlReport/HtmlReportGenerateService.Js.cs) (loads and concatenates 13 JS modules from [`Services/HtmlReport/js/`](../Services/HtmlReport/js/) with `{{STORAGE_KEY}}`/`{{REPORT_DATE}}` placeholders) | | `FolderDiffService` | [`Services/FolderDiffService.cs`](../Services/FolderDiffService.cs) | [`Services/FolderDiffService.ILPrecompute.cs`](../Services/FolderDiffService.ILPrecompute.cs), [`…DiffClassification.cs`](../Services/FolderDiffService.DiffClassification.cs) | -| `ReportGenerateService` | [`Services/ReportGenerateService.cs`](../Services/ReportGenerateService.cs) | [`Services/SectionWriters/HeaderSectionWriter.cs`](../Services/SectionWriters/HeaderSectionWriter.cs), [`…LegendSectionWriter.cs`](../Services/SectionWriters/LegendSectionWriter.cs), [`…IgnoredFilesSectionWriter.cs`](../Services/SectionWriters/IgnoredFilesSectionWriter.cs), [`…UnchangedFilesSectionWriter.cs`](../Services/SectionWriters/UnchangedFilesSectionWriter.cs), [`…AddedFilesSectionWriter.cs`](../Services/SectionWriters/AddedFilesSectionWriter.cs), [`…RemovedFilesSectionWriter.cs`](../Services/SectionWriters/RemovedFilesSectionWriter.cs), [`…ModifiedFilesSectionWriter.cs`](../Services/SectionWriters/ModifiedFilesSectionWriter.cs), [`…SummarySectionWriter.cs`](../Services/SectionWriters/SummarySectionWriter.cs), [`…ILCacheStatsSectionWriter.cs`](../Services/SectionWriters/ILCacheStatsSectionWriter.cs), [`…WarningsSectionWriter.cs`](../Services/SectionWriters/WarningsSectionWriter.cs) | +| `ReportGenerateService` | [`Services/ReportGenerateService.cs`](../Services/ReportGenerateService.cs) | [`Services/ReportGenerateService.Helpers.cs`](../Services/ReportGenerateService.Helpers.cs) (report file output helpers, Markdown display helpers, disassembler warning rendering), [`Services/SectionWriters/HeaderSectionWriter.cs`](../Services/SectionWriters/HeaderSectionWriter.cs), [`…LegendSectionWriter.cs`](../Services/SectionWriters/LegendSectionWriter.cs), [`…IgnoredFilesSectionWriter.cs`](../Services/SectionWriters/IgnoredFilesSectionWriter.cs), [`…UnchangedFilesSectionWriter.cs`](../Services/SectionWriters/UnchangedFilesSectionWriter.cs), [`…AddedFilesSectionWriter.cs`](../Services/SectionWriters/AddedFilesSectionWriter.cs), [`…RemovedFilesSectionWriter.cs`](../Services/SectionWriters/RemovedFilesSectionWriter.cs), [`…ModifiedFilesSectionWriter.cs`](../Services/SectionWriters/ModifiedFilesSectionWriter.cs), [`…SummarySectionWriter.cs`](../Services/SectionWriters/SummarySectionWriter.cs), [`…ILCacheStatsSectionWriter.cs`](../Services/SectionWriters/ILCacheStatsSectionWriter.cs), [`…WarningsSectionWriter.cs`](../Services/SectionWriters/WarningsSectionWriter.cs) | | `AssemblyMethodAnalyzer` | [`Services/AssemblyMethodAnalyzer.cs`](../Services/AssemblyMethodAnalyzer.cs) | [`Services/AssemblyMethodAnalyzer.Comparers.cs`](../Services/AssemblyMethodAnalyzer.Comparers.cs) (type/method/property/field comparison), [`…MetadataHelpers.cs`](../Services/AssemblyMethodAnalyzer.MetadataHelpers.cs) (snapshot construction, signature building), [`…AccessHelpers.cs`](../Services/AssemblyMethodAnalyzer.AccessHelpers.cs) (access/modifier extraction, type kind detection, IL byte reading, `DecodeTypeSpecification` for generic base types/interfaces), [`…SignatureProvider.cs`](../Services/AssemblyMethodAnalyzer.SignatureProvider.cs) (generic context, signature type provider, `StripGenericArity` for arity suffix removal, nested type reference resolution) | | `DepsJsonAnalyzer` | [`Services/DepsJsonAnalyzer.cs`](../Services/DepsJsonAnalyzer.cs) | (single file) Structured dependency change analysis for `.deps.json` files | | `NuGetVulnerabilityService` | [`Services/NuGetVulnerabilityService.cs`](../Services/NuGetVulnerabilityService.cs) | (single file) Fetches NuGet V3 vulnerability data, skips empty dependency sets, deduplicates repeated page URLs/advisories, and checks package versions against known advisories | | `NuGetVersionRange` | [`Services/NuGetVersionRange.cs`](../Services/NuGetVersionRange.cs) | (single file) NuGet version range interval notation parser | | `AssemblySdkVersionReader` | [`Services/AssemblySdkVersionReader.cs`](../Services/AssemblySdkVersionReader.cs) | (single file) Extracts TargetFrameworkAttribute from .NET assemblies via System.Reflection.Metadata and formats display strings (e.g. `.NET 8.0`, `.NET 6.0` → `.NET 8.0`) | +| `ILOutputService` | [`Services/ILOutputService.cs`](../Services/ILOutputService.cs) | [`Services/ILOutputService.Comparison.cs`](../Services/ILOutputService.Comparison.cs) (IL filtering, block-aware comparison, and disassembler label helpers) | | `FileDiffService` | [`Services/FileDiffService.cs`](../Services/FileDiffService.cs) | [`Services/FileDiffService.TextComparison.cs`](../Services/FileDiffService.TextComparison.cs) (sequential/chunk-parallel text comparison, memory-budget-aware parallelism) | | `DotNetDisassembleService` | [`Services/DotNetDisassembleService.cs`](../Services/DotNetDisassembleService.cs) | [`Services/DotNetDisassembleService.VersionLabel.cs`](../Services/DotNetDisassembleService.VersionLabel.cs) (version/label management, tool fingerprinting, process execution, usage recording, best-effort temp cleanup warnings), [`Services/DotNetDisassembleService.Streaming.cs`](../Services/DotNetDisassembleService.Streaming.cs) (line-based streaming disassembly, avoids LOH string allocations) | | `DotNetDisassemblerProvider` | [`Services/DotNetDisassemblerProvider.cs`](../Services/DotNetDisassemblerProvider.cs) | (single file) Built-in `IDisassemblerProvider` implementation wrapping `IDotNetDisassembleService`. Logs recoverable detection/disassembly failures with provider display name and file extension before falling back so plugin authors can provide alternative disassemblers for non-.NET file types | @@ -1042,17 +1043,18 @@ dotnet run -- "/path/old" "/path/new" "label" --threads 4 --skip-il --config /et | クラス | メインファイル | Partial ファイル | | --- | --- | --- | -| `ProgramRunner` | [`ProgramRunner.cs`](../ProgramRunner.cs) | [`Runner/ProgramRunner.Types.cs`](../Runner/ProgramRunner.Types.cs)(ネスト型: `RunArguments`, `RunCompletionState`, `ProgramExitCode`, `ProgramRunResult`, `StepResult`)、[`Runner/ProgramRunner.HelpText.cs`](../Runner/ProgramRunner.HelpText.cs)(CLI ヘルプメッセージ)、[`Runner/ProgramRunner.Config.cs`](../Runner/ProgramRunner.Config.cs)(設定読込・バリデーション・CLI オーバーライド)、[`Runner/ProgramRunner.Wizard.cs`](../Runner/ProgramRunner.Wizard.cs)(対話ウィザードモード)、[`Runner/ProgramRunner.OpenFolder.cs`](../Runner/ProgramRunner.OpenFolder.cs)(フォルダ開放の早期終了コマンド) | +| `ProgramRunner` | [`ProgramRunner.cs`](../ProgramRunner.cs) | [`Runner/ProgramRunner.Core.cs`](../Runner/ProgramRunner.Core.cs)(共有定数、注入サービス、コンストラクター)、[`Runner/ProgramRunner.Types.cs`](../Runner/ProgramRunner.Types.cs)(ネスト型: `RunArguments`, `RunCompletionState`, `ProgramExitCode`, `ProgramRunResult`, `StepResult`)、[`Runner/ProgramRunner.HelpText.cs`](../Runner/ProgramRunner.HelpText.cs)(CLI ヘルプメッセージ)、[`Runner/ProgramRunner.Config.cs`](../Runner/ProgramRunner.Config.cs)(設定読込・バリデーション・CLI オーバーライド)、[`Runner/ProgramRunner.Wizard.cs`](../Runner/ProgramRunner.Wizard.cs)(対話ウィザードモード)、[`Runner/ProgramRunner.OpenFolder.cs`](../Runner/ProgramRunner.OpenFolder.cs)(フォルダ開放の早期終了コマンド) | | `ConfigSettings` | [`Models/ConfigSettings.cs`](../Models/ConfigSettings.cs) | [`Models/ConfigSettings.ReportSettings.cs`](../Models/ConfigSettings.ReportSettings.cs)(レポート出力制御)、[`Models/ConfigSettings.ILSettings.cs`](../Models/ConfigSettings.ILSettings.cs)(IL 比較・キャッシュ・逆アセンブラ)、[`Models/ConfigSettings.DiffSettings.cs`](../Models/ConfigSettings.DiffSettings.cs)(並列処理・ネットワーク・インライン差分)、[`Models/ConfigSettings.PluginSettings.cs`](../Models/ConfigSettings.PluginSettings.cs)(プラグイン設定) | | `ConfigSettingsBuilder` | [`Models/ConfigSettingsBuilder.cs`](../Models/ConfigSettingsBuilder.cs) | [`Models/ConfigSettingsBuilder.ReportSettings.cs`](../Models/ConfigSettingsBuilder.ReportSettings.cs)(レポート出力制御)、[`Models/ConfigSettingsBuilder.ILSettings.cs`](../Models/ConfigSettingsBuilder.ILSettings.cs)(IL 比較・キャッシュ・逆アセンブラ)、[`Models/ConfigSettingsBuilder.DiffSettings.cs`](../Models/ConfigSettingsBuilder.DiffSettings.cs)(並列処理・ネットワーク・インライン差分)、[`Models/ConfigSettingsBuilder.PluginSettings.cs`](../Models/ConfigSettingsBuilder.PluginSettings.cs)(プラグイン設定) | -| `HtmlReportGenerateService` | [`Services/HtmlReportGenerateService.cs`](../Services/HtmlReportGenerateService.cs) | [`Services/HtmlReport/HtmlReportGenerateService.Sections.cs`](../Services/HtmlReport/HtmlReportGenerateService.Sections.cs)(レポートセクション生成)、[`…DetailRows.cs`](../Services/HtmlReport/HtmlReportGenerateService.DetailRows.cs)(インライン差分、セマンティック変更、依存関係変更の詳細行)、[`…Helpers.cs`](../Services/HtmlReport/HtmlReportGenerateService.Helpers.cs), [`…Css.cs`](../Services/HtmlReport/HtmlReportGenerateService.Css.cs) ([`diff_report.css`](../Services/HtmlReport/diff_report.css) 埋め込みリソースを読み込み), [`…Js.cs`](../Services/HtmlReport/HtmlReportGenerateService.Js.cs) ([`Services/HtmlReport/js/`](../Services/HtmlReport/js/) の13 個の JS モジュールを結合し、`{{STORAGE_KEY}}`/`{{REPORT_DATE}}` プレースホルダー置換して読み込み) | +| `HtmlReportGenerateService` | [`Services/HtmlReportGenerateService.cs`](../Services/HtmlReportGenerateService.cs) | [`Services/HtmlReport/HtmlReportGenerateService.Sections.cs`](../Services/HtmlReport/HtmlReportGenerateService.Sections.cs)(レポートセクション生成)、[`…DetailRows.cs`](../Services/HtmlReport/HtmlReportGenerateService.DetailRows.cs)(インライン差分、依存関係変更の詳細行、および共有 `
` 描画ヘルパー)、[`…SemanticChanges.cs`](../Services/HtmlReport/HtmlReportGenerateService.SemanticChanges.cs)(アセンブリセマンティック変更の詳細行)、[`…Helpers.cs`](../Services/HtmlReport/HtmlReportGenerateService.Helpers.cs), [`…Css.cs`](../Services/HtmlReport/HtmlReportGenerateService.Css.cs) ([`diff_report.css`](../Services/HtmlReport/diff_report.css) 埋め込みリソースを読み込み), [`…Js.cs`](../Services/HtmlReport/HtmlReportGenerateService.Js.cs) ([`Services/HtmlReport/js/`](../Services/HtmlReport/js/) の13 個の JS モジュールを結合し、`{{STORAGE_KEY}}`/`{{REPORT_DATE}}` プレースホルダー置換して読み込み) | | `FolderDiffService` | [`Services/FolderDiffService.cs`](../Services/FolderDiffService.cs) | [`Services/FolderDiffService.ILPrecompute.cs`](../Services/FolderDiffService.ILPrecompute.cs), [`…DiffClassification.cs`](../Services/FolderDiffService.DiffClassification.cs) | -| `ReportGenerateService` | [`Services/ReportGenerateService.cs`](../Services/ReportGenerateService.cs) | [`Services/SectionWriters/HeaderSectionWriter.cs`](../Services/SectionWriters/HeaderSectionWriter.cs), [`…LegendSectionWriter.cs`](../Services/SectionWriters/LegendSectionWriter.cs), [`…IgnoredFilesSectionWriter.cs`](../Services/SectionWriters/IgnoredFilesSectionWriter.cs), [`…UnchangedFilesSectionWriter.cs`](../Services/SectionWriters/UnchangedFilesSectionWriter.cs), [`…AddedFilesSectionWriter.cs`](../Services/SectionWriters/AddedFilesSectionWriter.cs), [`…RemovedFilesSectionWriter.cs`](../Services/SectionWriters/RemovedFilesSectionWriter.cs), [`…ModifiedFilesSectionWriter.cs`](../Services/SectionWriters/ModifiedFilesSectionWriter.cs), [`…SummarySectionWriter.cs`](../Services/SectionWriters/SummarySectionWriter.cs), [`…ILCacheStatsSectionWriter.cs`](../Services/SectionWriters/ILCacheStatsSectionWriter.cs), [`…WarningsSectionWriter.cs`](../Services/SectionWriters/WarningsSectionWriter.cs) | +| `ReportGenerateService` | [`Services/ReportGenerateService.cs`](../Services/ReportGenerateService.cs) | [`Services/ReportGenerateService.Helpers.cs`](../Services/ReportGenerateService.Helpers.cs)(レポート出力補助、Markdown 表示補助、逆アセンブラ警告描画)、[`Services/SectionWriters/HeaderSectionWriter.cs`](../Services/SectionWriters/HeaderSectionWriter.cs), [`…LegendSectionWriter.cs`](../Services/SectionWriters/LegendSectionWriter.cs), [`…IgnoredFilesSectionWriter.cs`](../Services/SectionWriters/IgnoredFilesSectionWriter.cs), [`…UnchangedFilesSectionWriter.cs`](../Services/SectionWriters/UnchangedFilesSectionWriter.cs), [`…AddedFilesSectionWriter.cs`](../Services/SectionWriters/AddedFilesSectionWriter.cs), [`…RemovedFilesSectionWriter.cs`](../Services/SectionWriters/RemovedFilesSectionWriter.cs), [`…ModifiedFilesSectionWriter.cs`](../Services/SectionWriters/ModifiedFilesSectionWriter.cs), [`…SummarySectionWriter.cs`](../Services/SectionWriters/SummarySectionWriter.cs), [`…ILCacheStatsSectionWriter.cs`](../Services/SectionWriters/ILCacheStatsSectionWriter.cs), [`…WarningsSectionWriter.cs`](../Services/SectionWriters/WarningsSectionWriter.cs) | | `AssemblyMethodAnalyzer` | [`Services/AssemblyMethodAnalyzer.cs`](../Services/AssemblyMethodAnalyzer.cs) | [`Services/AssemblyMethodAnalyzer.Comparers.cs`](../Services/AssemblyMethodAnalyzer.Comparers.cs)(型/メソッド/プロパティ/フィールド比較)、[`…MetadataHelpers.cs`](../Services/AssemblyMethodAnalyzer.MetadataHelpers.cs)(スナップショット構築、シグネチャ構築)、[`…AccessHelpers.cs`](../Services/AssemblyMethodAnalyzer.AccessHelpers.cs)(アクセス修飾子抽出、型種別判定、IL バイト読み取り、ジェネリック基底型/インターフェースの `DecodeTypeSpecification`)、[`…SignatureProvider.cs`](../Services/AssemblyMethodAnalyzer.SignatureProvider.cs)(ジェネリックコンテキスト、シグネチャ型プロバイダ、アリティ接尾辞除去の `StripGenericArity`、ネスト型参照解決) | | `DepsJsonAnalyzer` | [`Services/DepsJsonAnalyzer.cs`](../Services/DepsJsonAnalyzer.cs) | (単一ファイル)`.deps.json` ファイルの構造化された依存関係変更分析 | | `NuGetVulnerabilityService` | [`Services/NuGetVulnerabilityService.cs`](../Services/NuGetVulnerabilityService.cs) | (単一ファイル)NuGet V3 脆弱性データの取得、空の依存関係集合の短絡 return、重複ページ URL / advisory の排除、およびパッケージバージョンの既知アドバイザリ突合 | | `NuGetVersionRange` | [`Services/NuGetVersionRange.cs`](../Services/NuGetVersionRange.cs) | (単一ファイル)NuGet バージョン範囲の区間記法パーサ | | `AssemblySdkVersionReader` | [`Services/AssemblySdkVersionReader.cs`](../Services/AssemblySdkVersionReader.cs) | (単一ファイル)System.Reflection.Metadata 経由で .NET アセンブリから TargetFrameworkAttribute を抽出し、表示文字列をフォーマット(例: `.NET 8.0`、`.NET 6.0` → `.NET 8.0`) | +| `ILOutputService` | [`Services/ILOutputService.cs`](../Services/ILOutputService.cs) | [`Services/ILOutputService.Comparison.cs`](../Services/ILOutputService.Comparison.cs)(IL フィルタリング、ブロック単位比較、逆アセンブラ表示ラベル補助) | | `FileDiffService` | [`Services/FileDiffService.cs`](../Services/FileDiffService.cs) | [`Services/FileDiffService.TextComparison.cs`](../Services/FileDiffService.TextComparison.cs)(逐次/チャンク並列テキスト比較、メモリ予算考慮の並列度制御) | | `DotNetDisassembleService` | [`Services/DotNetDisassembleService.cs`](../Services/DotNetDisassembleService.cs) | [`Services/DotNetDisassembleService.VersionLabel.cs`](../Services/DotNetDisassembleService.VersionLabel.cs)(バージョン/ラベル管理、ツールフィンガープリント、プロセス実行、使用記録、一時ファイル cleanup warning), [`Services/DotNetDisassembleService.Streaming.cs`](../Services/DotNetDisassembleService.Streaming.cs)(行単位ストリーミング逆アセンブル、LOH 文字列割り当て回避) | | `DotNetDisassemblerProvider` | [`Services/DotNetDisassemblerProvider.cs`](../Services/DotNetDisassemblerProvider.cs) | (単一ファイル)`IDotNetDisassembleService` をラップする組み込み `IDisassemblerProvider` 実装。recoverable な検出失敗・逆アセンブル失敗を provider 表示名と拡張子付き warning に残しつつフォールバックし、プラグイン開発者が非 .NET ファイル用の代替逆アセンブラを提供可能にする |