diff --git a/FolderDiffIL4DotNet.Benchmarks/FolderDiffBenchmarks.cs b/FolderDiffIL4DotNet.Benchmarks/FolderDiffBenchmarks.cs index b7191a17..802332da 100644 --- a/FolderDiffIL4DotNet.Benchmarks/FolderDiffBenchmarks.cs +++ b/FolderDiffIL4DotNet.Benchmarks/FolderDiffBenchmarks.cs @@ -58,8 +58,7 @@ public bool HashCompare_SmallFile() { var files = Directory.GetFiles(_smallDirPath).Take(2).ToArray(); if (files.Length < 2) return false; - var comparer = new FileComparer(); - return comparer.ComputeMd5Hash(files[0]) == comparer.ComputeMd5Hash(files[1]); + return FileComparer.ComputeFileMd5Hex(files[0]) == FileComparer.ComputeFileMd5Hex(files[1]); } private static string CreateTempFolderWithFiles(string prefix, int fileCount, int fileSizeBytes) diff --git a/FolderDiffIL4DotNet.Core/IO/FileSystemUtility.cs b/FolderDiffIL4DotNet.Core/IO/FileSystemUtility.cs index 9902453e..78c122ce 100644 --- a/FolderDiffIL4DotNet.Core/IO/FileSystemUtility.cs +++ b/FolderDiffIL4DotNet.Core/IO/FileSystemUtility.cs @@ -284,7 +284,7 @@ private static bool IsLikelyWindowsNetworkPath(string absolutePath) return true; } - string root = TryGetPathRoot(absolutePath); + string? root = TryGetPathRoot(absolutePath); return !string.IsNullOrEmpty(root) && IsNetworkDrive(root); } @@ -325,13 +325,13 @@ private static bool IsLikelyMacNetworkPath(string absolutePath) private static bool IsLikelyUnixNetworkPath(string absolutePath) { - string mountsFile = GetUnixMountsFilePath(); + string? mountsFile = GetUnixMountsFilePath(); if (mountsFile == null) { return false; } - string fullPath = TryGetFullPath(absolutePath); + string? fullPath = TryGetFullPath(absolutePath); if (fullPath == null) { return false; @@ -343,7 +343,7 @@ private static bool IsLikelyUnixNetworkPath(string absolutePath) return false; } - string bestFsType = GetBestMatchingMountFileSystemType(fullPath, mountLines); + string? bestFsType = GetBestMatchingMountFileSystemType(fullPath, mountLines); return !string.IsNullOrEmpty(bestFsType) && s_unixNetworkFsTypes.Contains(bestFsType); } diff --git a/FolderDiffIL4DotNet.Tests/Services/FileDiffServiceUnitTests.cs b/FolderDiffIL4DotNet.Tests/Services/FileDiffServiceUnitTests.cs index 3ddb02a7..6e9d410b 100644 --- a/FolderDiffIL4DotNet.Tests/Services/FileDiffServiceUnitTests.cs +++ b/FolderDiffIL4DotNet.Tests/Services/FileDiffServiceUnitTests.cs @@ -468,7 +468,7 @@ public Task ReadChunkAsync(string fileAbsolutePath, long offset, Memory filesAbsolutePaths, int maxParal return Task.CompletedTask; } - public Task<(bool AreEqual, string DisassemblerLabel)> DiffDotNetAssembliesAsync(string fileRelativePath, string oldFolderAbsolutePath, string newFolderAbsolutePath, bool shouldOutputIlText) + public Task<(bool AreEqual, string? DisassemblerLabel)> DiffDotNetAssembliesAsync(string fileRelativePath, string oldFolderAbsolutePath, string newFolderAbsolutePath, bool shouldOutputIlText) { DiffCalls.Add(new DiffCall(fileRelativePath, oldFolderAbsolutePath, newFolderAbsolutePath, shouldOutputIlText)); if (DiffException != null) diff --git a/FolderDiffIL4DotNet.csproj b/FolderDiffIL4DotNet.csproj index 18ee46d8..63bc531c 100644 --- a/FolderDiffIL4DotNet.csproj +++ b/FolderDiffIL4DotNet.csproj @@ -3,7 +3,7 @@ Exe net8.0 - $(DefaultItemExcludes);FolderDiffIL4DotNet.Tests/**;FolderDiffIL4DotNet.Core/** + $(DefaultItemExcludes);FolderDiffIL4DotNet.Tests/**;FolderDiffIL4DotNet.Core/**;FolderDiffIL4DotNet.Benchmarks/** true true enable diff --git a/Models/FileDiffResultLists.cs b/Models/FileDiffResultLists.cs index ad8aae8f..55c5575e 100644 --- a/Models/FileDiffResultLists.cs +++ b/Models/FileDiffResultLists.cs @@ -212,7 +212,7 @@ public void RecordIgnoredFile(string fileRelativePath, IgnoredFileLocation locat /// Records the disassembler tool name and version used. /// 使用した逆アセンブラ名とバージョンを記録します。 /// - public void RecordDisassemblerToolVersion(string toolName, string version, bool fromCache = false) + public void RecordDisassemblerToolVersion(string toolName, string? version, bool fromCache = false) { if (string.IsNullOrWhiteSpace(toolName)) { diff --git a/ProgramRunner.cs b/ProgramRunner.cs index 9b535101..dbdef19b 100644 --- a/ProgramRunner.cs +++ b/ProgramRunner.cs @@ -123,33 +123,33 @@ private async Task RunWithResultAsync(string[] args, CliOption var runArgumentsResult = TryValidateAndBuildRunArguments(args, opts); if (!runArgumentsResult.IsSuccess) { - return runArgumentsResult.Failure; + return runArgumentsResult.Failure!; } - var runArguments = runArgumentsResult.Value; + var runArguments = runArgumentsResult.Value!; var prepareReportsDirectoryResult = TryPrepareReportsDirectory(runArguments.ReportsFolderAbsolutePath); if (!prepareReportsDirectoryResult.IsSuccess) { - return prepareReportsDirectoryResult.Failure; + return prepareReportsDirectoryResult.Failure!; } var configResult = await TryLoadConfigurationAsync(opts.ConfigPath); if (!configResult.IsSuccess) { - return configResult.Failure; + return configResult.Failure!; } - var config = configResult.Value; + var config = configResult.Value!; ApplyCliOverrides(config, opts); var completionStateResult = await TryExecuteRunAsync(runArguments, config, appVersion, computerName); if (!completionStateResult.IsSuccess) { - return completionStateResult.Failure; + return completionStateResult.Failure!; } _logger.LogMessage(AppLogLevel.Info, LOG_APP_FINISHED, shouldOutputMessageToConsole: true, ConsoleColor.Green); - return ProgramRunResult.Success(completionStateResult.Value); + return ProgramRunResult.Success(completionStateResult.Value!); } catch (Exception ex) { @@ -274,7 +274,7 @@ private StepResult TryPrepareReportsDirectory(string reportsFolderAbsolute /// Returns the configuration loading phase as a typed result. /// 設定読込フェーズを型付き結果として返します。 /// - private async Task> TryLoadConfigurationAsync(string configPath) + private async Task> TryLoadConfigurationAsync(string? configPath) { try { @@ -373,7 +373,7 @@ private static void PrepareReportsDirectory(string reportsFolderAbsolutePath) Directory.CreateDirectory(reportsFolderAbsolutePath); } - private async Task LoadConfigurationAsync(string configPath) + private async Task LoadConfigurationAsync(string? configPath) { _logger.LogMessage(AppLogLevel.Info, LOG_LOADING_CONFIGURATION, shouldOutputMessageToConsole: true); var config = await _configService.LoadConfigAsync(configPath); @@ -472,7 +472,7 @@ private static bool ShouldSkipExitPrompt(CliOptions opts) || Console.IsOutputRedirected || Console.IsErrorRedirected; - private static ILCache CreateIlCache(ConfigSettings config, ILoggerService logger) + private static ILCache? CreateIlCache(ConfigSettings config, ILoggerService logger) { return RunScopeBuilder.CreateIlCache(config, logger); } @@ -496,7 +496,7 @@ internal static string FormatElapsedTime(TimeSpan elapsed) /// Prints the effective configuration (after JSON load + environment variable overrides) to stdout as JSON. /// 有効な設定(JSON 読込 + 環境変数オーバーライド適用後)を JSON として標準出力に書き出します。 /// - private async Task PrintConfigAsync(string configPath) + private async Task PrintConfigAsync(string? configPath) { try { diff --git a/Runner/RunScopeBuilder.cs b/Runner/RunScopeBuilder.cs index 65b6212f..f24adcd6 100644 --- a/Runner/RunScopeBuilder.cs +++ b/Runner/RunScopeBuilder.cs @@ -51,7 +51,7 @@ internal static ServiceProvider Build(ConfigSettings config, DiffExecutionContex services.AddSingleton(executionContext); services.AddScoped(); services.AddScoped(); - services.AddScoped(sp => CreateIlCache(config, sp.GetRequiredService())); + services.AddScoped(sp => CreateIlCache(config, sp.GetRequiredService())!); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/Services/Caching/DotNetDisassemblerCache.cs b/Services/Caching/DotNetDisassemblerCache.cs index 6db60ac5..be844341 100644 --- a/Services/Caching/DotNetDisassemblerCache.cs +++ b/Services/Caching/DotNetDisassemblerCache.cs @@ -54,9 +54,9 @@ public async Task GetDisassemblerVersionAsync(string disassembleCommandW var (disassemblerKind, disassemblerVersionCacheKey, disassemblerExe) = GetDisassemblerInfo(disassembleCommandWithArguments); return disassemblerKind switch { - DisassemblerKind.DotnetIldasm => await GetVersionForDotnetIldasmAsync(disassemblerVersionCacheKey, disassemblerExe), - DisassemblerKind.Ildasm => await GetVersionForIldasmAsync(disassemblerVersionCacheKey, disassemblerExe), - DisassemblerKind.Ilspy => await GetVersionForIlspyAsync(disassemblerVersionCacheKey, disassemblerExe), + DisassemblerKind.DotnetIldasm => await GetVersionForDotnetIldasmAsync(disassemblerVersionCacheKey!, disassemblerExe!), + DisassemblerKind.Ildasm => await GetVersionForIldasmAsync(disassemblerVersionCacheKey!, disassemblerExe!), + DisassemblerKind.Ilspy => await GetVersionForIlspyAsync(disassemblerVersionCacheKey!, disassemblerExe!), _ => throw new InvalidOperationException($"Failed to determine disassembler version for label: '{disassembleCommandWithArguments}'.") }; } @@ -65,7 +65,7 @@ public async Task GetDisassemblerVersionAsync(string disassembleCommandW /// Extracts the disassembler kind, cache key, and executable from a command label. /// コマンドラベルから逆アセンブラ種別・キャッシュキー・実行ファイル名を抽出します。 /// - private static (DisassemblerKind disassemblerKind, string disassemblerVersionCacheKey, string disassemblerExe) GetDisassemblerInfo(string disassembleCommandWithArguments) + private static (DisassemblerKind disassemblerKind, string? disassemblerVersionCacheKey, string? disassemblerExe) GetDisassemblerInfo(string disassembleCommandWithArguments) { var tokens = ProcessHelper.TokenizeCommand(disassembleCommandWithArguments); if (tokens.Count == 0) diff --git a/Services/ConfigService.cs b/Services/ConfigService.cs index daef13e6..97e8d78b 100644 --- a/Services/ConfigService.cs +++ b/Services/ConfigService.cs @@ -26,7 +26,7 @@ public sealed class ConfigService /// config.json を指定パス(または既定のアプリケーションベースディレクトリ)から非同期で読み込み、 /// にデシリアライズした後、設定値の整合性を検証します。 /// - public async Task LoadConfigAsync(string configFilePath = null) + public async Task LoadConfigAsync(string? configFilePath = null) { try { diff --git a/Services/DotNetDisassembleService.cs b/Services/DotNetDisassembleService.cs index da8ae231..6694507f 100644 --- a/Services/DotNetDisassembleService.cs +++ b/Services/DotNetDisassembleService.cs @@ -119,7 +119,7 @@ public DotNetDisassembleService(ConfigSettings config, ILCache? ilCache, FileDif var (success, ilText, disassembleCommandAndItsVersionWithArguments, error) = await TryDisassembleAsync(candidateDisassembleCommand, dotNetAssemblyfileAbsolutePath, allowCache: true, recordUsage: true); if (success) { - return (ilText, disassembleCommandAndItsVersionWithArguments); + return (ilText!, disassembleCommandAndItsVersionWithArguments!); } if (error != null) { @@ -174,19 +174,19 @@ public DotNetDisassembleService(ConfigSettings config, ILCache? ilCache, FileDif continue; } - if (!AreSameDisassemblerVersion(oldResult.DisassembleCommandAndItsVersionWithArguments, newResult.DisassembleCommandAndItsVersionWithArguments)) + if (!AreSameDisassemblerVersion(oldResult.DisassembleCommandAndItsVersionWithArguments!, newResult.DisassembleCommandAndItsVersionWithArguments!)) { lastError = new InvalidOperationException($"Disassembler version mismatch for command '{candidateDisassembleCommand}'. old='{oldResult.DisassembleCommandAndItsVersionWithArguments}', new='{newResult.DisassembleCommandAndItsVersionWithArguments}'."); continue; } - RecordDisassemblerUsage(candidateDisassembleCommand, oldResult.DisassembleCommandAndItsVersionWithArguments); - RecordDisassemblerUsage(candidateDisassembleCommand, newResult.DisassembleCommandAndItsVersionWithArguments); + RecordDisassemblerUsage(candidateDisassembleCommand, oldResult.DisassembleCommandAndItsVersionWithArguments!); + RecordDisassemblerUsage(candidateDisassembleCommand, newResult.DisassembleCommandAndItsVersionWithArguments!); return ( - oldResult.IlText, - oldResult.DisassembleCommandAndItsVersionWithArguments, - newResult.IlText, - newResult.DisassembleCommandAndItsVersionWithArguments); + oldResult.IlText!, + oldResult.DisassembleCommandAndItsVersionWithArguments!, + newResult.IlText!, + newResult.DisassembleCommandAndItsVersionWithArguments!); } catch (System.ComponentModel.Win32Exception ex) { @@ -212,14 +212,14 @@ public Task PrefetchIlCacheAsync(IEnumerable dotNetAssemblyFilesAbsolute /// 指定コマンドでアセンブリの逆アセンブルを試行します。必要に応じて一時ASCIIパスを生成し、 /// 複数の引数セットを順に試します。 /// - private async Task<(bool Success, string IlText, string DisassembleCommandAndItsVersionWithArguments, Exception Error)> TryDisassembleAsync( + private async Task<(bool Success, string? IlText, string? DisassembleCommandAndItsVersionWithArguments, Exception? Error)> TryDisassembleAsync( string disassembleCommand, string dotNetAssemblyFileAbsolutePath, bool allowCache, bool recordUsage) { Exception? lastError = null; - string tempAsciiPath = CreateAsciiTempCopyIfNeeded(dotNetAssemblyFileAbsolutePath); + string? tempAsciiPath = CreateAsciiTempCopyIfNeeded(dotNetAssemblyFileAbsolutePath); try { @@ -238,7 +238,7 @@ public Task PrefetchIlCacheAsync(IEnumerable dotNetAssemblyFilesAbsolute } finally { - FileSystemUtility.DeleteFileSilent(tempAsciiPath); + if (tempAsciiPath != null) FileSystemUtility.DeleteFileSilent(tempAsciiPath); } return (Success: false, IlText: null, DisassembleCommandAndItsVersionWithArguments: null, Error: lastError); @@ -248,10 +248,10 @@ public Task PrefetchIlCacheAsync(IEnumerable dotNetAssemblyFilesAbsolute /// Tries disassembly with a single argument set: cache check, process execution, cache store. /// 1つの引数セットで逆アセンブルを試行します。キャッシュ事前チェック→プロセス実行→キャッシュ格納までを担当。 /// - private async Task<(bool Success, string IlText, string DisassembleCommandAndItsVersionWithArguments, Exception Error)> TryDisassembleWithArguments( + private async Task<(bool Success, string? IlText, string? DisassembleCommandAndItsVersionWithArguments, Exception? Error)> TryDisassembleWithArguments( string disassembleCommand, string dotNetAssemblyFileAbsolutePath, - (string workingDirectory, string[] args, string tempOut) argset, + (string workingDirectory, string[] args, string? tempOut) argset, bool allowCache, bool recordUsage) { @@ -285,7 +285,7 @@ public Task PrefetchIlCacheAsync(IEnumerable dotNetAssemblyFilesAbsolute // Success — clear the blacklist state. // 正常終了したらブラックリスト状態を解除。 ResetDisassembleFailure(disassembleCommand); - var ilText = await ReadIlTextAfterSuccessAsync(IsIlspyCommand(disassembleCommand), argset, stdout); + var ilText = await ReadIlTextAfterSuccessAsync(IsIlspyCommand(disassembleCommand), argset, stdout!); if (string.IsNullOrEmpty(label)) { // On cache-miss path the label may not have been computed yet. @@ -316,7 +316,7 @@ public Task PrefetchIlCacheAsync(IEnumerable dotNetAssemblyFilesAbsolute /// IL キャッシュへのヒットを試みます。ミス時も Label を返すことで、 /// 後続のキャッシュ格納でバージョン取得の再実行を省略できます。 /// - private async Task<(bool Hit, string IlText, string Label)> TryCacheHitAsync( + private async Task<(bool Hit, string? IlText, string? Label)> TryCacheHitAsync( string disassembleCommand, string dotNetAssemblyFileAbsolutePath, string[] args, @@ -371,7 +371,7 @@ public Task PrefetchIlCacheAsync(IEnumerable dotNetAssemblyFilesAbsolute /// private static async Task ReadIlTextAfterSuccessAsync( bool isIlspy, - (string workingDirectory, string[] args, string tempOut) argset, + (string workingDirectory, string[] args, string? tempOut) argset, string stdout) { if (isIlspy && !string.IsNullOrEmpty(argset.tempOut) && File.Exists(argset.tempOut)) @@ -463,14 +463,14 @@ private async Task TryStoreToCacheAsync( /// Enumerates argument sets to try, based on the command type. /// コマンド種別に応じた試行用の引数セットを列挙します。 /// - private static IEnumerable<(string workingDirectory, string[] args, string tempOut)> BuildArgSets(string disassembleCommand, string disassemblerFileAbsolutePath, string tempAsciiPath) + private static IEnumerable<(string workingDirectory, string[] args, string? tempOut)> BuildArgSets(string disassembleCommand, string disassemblerFileAbsolutePath, string? tempAsciiPath) { var disassemblerFileDirectoryAbsolutePath = Path.GetDirectoryName(disassemblerFileAbsolutePath) ?? Environment.CurrentDirectory; var disassemblerFileNameOnly = Path.GetFileName(disassemblerFileAbsolutePath); var isDotnetMuxer = IsDotnetMuxer(disassembleCommand); var isIlspy = IsIlspyCommand(disassembleCommand); - var argSets = new List<(string workingDirectory, string[] args, string tempOut)>(); + var argSets = new List<(string workingDirectory, string[] args, string? tempOut)>(); if (!isIlspy) { argSets.Add((disassemblerFileDirectoryAbsolutePath, isDotnetMuxer ? [Constants.ILDASM_LABEL, disassemblerFileNameOnly] : [disassemblerFileNameOnly], null)); @@ -571,7 +571,7 @@ private static string BuildToolFingerprint(string disassembleCommandWithArgument return string.Join("|", fingerprints.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)); } - private static string ResolveExecutablePath(string command) => DisassemblerHelper.ResolveExecutablePath(command); + private static string? ResolveExecutablePath(string command) => DisassemblerHelper.ResolveExecutablePath(command); /// /// Launches the command, waits for exit, and returns exit code / stdout / stderr. @@ -579,7 +579,7 @@ private static string BuildToolFingerprint(string disassembleCommandWithArgument /// 指定コマンドを起動して終了を待ち、終了コードと標準出力/標準エラーを返します。 /// 起動失敗時は例外をタプルに含めて返します。 /// - private static async Task<(int ExitCode, string Stdout, string Stderr, Exception Error)> RunProcessAsync(string disassembleCommand, string workingDirectoryAbsolutePath, string[] args) + private static async Task<(int ExitCode, string? Stdout, string? Stderr, Exception? Error)> RunProcessAsync(string disassembleCommand, string workingDirectoryAbsolutePath, string[] args) { try { diff --git a/Services/FolderDiffService.cs b/Services/FolderDiffService.cs index e201eac0..51a0f42a 100644 --- a/Services/FolderDiffService.cs +++ b/Services/FolderDiffService.cs @@ -97,7 +97,7 @@ public FolderDiffService( FileDiffResultLists fileDiffResultLists, ILoggerService logger, IFileSystemService fileSystem, - IFolderDiffExecutionStrategy executionStrategy) + IFolderDiffExecutionStrategy? executionStrategy) { ArgumentNullException.ThrowIfNull(config); ArgumentNullException.ThrowIfNull(progressReporter); diff --git a/Services/IILOutputService.cs b/Services/IILOutputService.cs index 96d4ac76..1c1ef91b 100644 --- a/Services/IILOutputService.cs +++ b/Services/IILOutputService.cs @@ -21,6 +21,6 @@ public interface IILOutputService /// /// Whether to write IL text to files. / IL テキストをファイルに出力するかどうか。 /// A tuple containing an equality flag and the disassembler label used. / アセンブリが等価かどうかを示すフラグと、使用した逆アセンブラのラベルを含むタプル。 - Task<(bool AreEqual, string DisassemblerLabel)> DiffDotNetAssembliesAsync(string fileRelativePath, string oldFolderAbsolutePath, string newFolderAbsolutePath, bool shouldOutputIlText); + Task<(bool AreEqual, string? DisassemblerLabel)> DiffDotNetAssembliesAsync(string fileRelativePath, string oldFolderAbsolutePath, string newFolderAbsolutePath, bool shouldOutputIlText); } } diff --git a/Services/ILCachePrefetcher.cs b/Services/ILCachePrefetcher.cs index 6aa25c82..c64d0528 100644 --- a/Services/ILCachePrefetcher.cs +++ b/Services/ILCachePrefetcher.cs @@ -152,7 +152,7 @@ private async Task TryHitCacheForAssemblyAsync( foreach (var pattern in patterns) { var fullLabel = pattern + (string.IsNullOrEmpty(disassemblerVersion) ? string.Empty : $" (version: {disassemblerVersion})"); - if (await _ilCache.TryGetILAsync(dotNetAssemblyFileAbsolutePath, fullLabel) != null) + if (_ilCache != null && await _ilCache.TryGetILAsync(dotNetAssemblyFileAbsolutePath, fullLabel) != null) { Interlocked.Increment(ref _ilCacheHits); break; diff --git a/Services/ILOutputService.cs b/Services/ILOutputService.cs index d6ebd173..456b6685 100644 --- a/Services/ILOutputService.cs +++ b/Services/ILOutputService.cs @@ -122,7 +122,7 @@ public async Task PrecomputeAsync(IEnumerable filesAbsolutePaths, int ma /// old/new の .NET アセンブリを同一逆アセンブラで逆アセンブルし、MVID などの除外行を適用したうえで IL を比較します。 /// が true の場合は IL テキストをファイルに出力します。 /// - public async Task<(bool AreEqual, string DisassemblerLabel)> DiffDotNetAssembliesAsync(string fileRelativePath, string oldFolderAbsolutePath, string newFolderAbsolutePath, bool shouldOutputIlText) + public async Task<(bool AreEqual, string? DisassemblerLabel)> DiffDotNetAssembliesAsync(string fileRelativePath, string oldFolderAbsolutePath, string newFolderAbsolutePath, bool shouldOutputIlText) { string file1AbsolutePath = Path.Combine(oldFolderAbsolutePath, fileRelativePath); string file2AbsolutePath = Path.Combine(newFolderAbsolutePath, fileRelativePath); @@ -206,7 +206,7 @@ private static List GetNormalizedIlIgnoreContainingStrings(ConfigSetting /// Merges the disassembler labels used for old/new into a single comparison label. /// old/new で使用された逆アセンブラ表示ラベルを比較用に 1 つへまとめます。 /// - private static string BuildComparisonDisassemblerLabel(string commandStringOld, string commandStringNew) + private static string? BuildComparisonDisassemblerLabel(string commandStringOld, string commandStringNew) { var oldLabel = BuildToolAndVersionLabel(commandStringOld); var newLabel = BuildToolAndVersionLabel(commandStringNew); diff --git a/Services/ProgressReportService.cs b/Services/ProgressReportService.cs index 9d5d7fc3..1a5ce4d9 100644 --- a/Services/ProgressReportService.cs +++ b/Services/ProgressReportService.cs @@ -19,7 +19,7 @@ public sealed class ProgressReportService : IDisposable private const int FIXED_BAR_WIDTH = 32; private readonly string[] _keepAliveFrames; private string? _lastFormattedPercentage = null; - private string _labelPrefix = string.Empty; + private string? _labelPrefix = string.Empty; private double _lastPercentage = double.NegativeInfinity; private DateTime _lastConsoleWriteUtc = DateTime.MinValue; private static readonly TimeSpan KeepAliveInterval = TimeSpan.FromSeconds(5);