From f3244bbb10272820cb2334ecf1cc3016702f25c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sat, 14 Mar 2026 08:52:14 +0100 Subject: [PATCH 1/2] Improve module build versioning and owner summaries --- Module/Build/Build-Module.ps1 | 45 +- ...oduleBuilderBinaryConflictAdvisoryTests.cs | 88 +++ .../ModulePipelineRefreshManifestOnlyTests.cs | 6 +- PowerForge/Models/ModuleBuildResult.cs | 10 +- PowerForge/Models/ModuleBuildSpec.cs | 11 + PowerForge/Models/ModuleOwnerNote.cs | 55 ++ PowerForge/Models/ModulePipelineResult.cs | 9 +- PowerForge/Services/ModuleBuildPipeline.cs | 30 +- PowerForge/Services/ModuleBuilder.cs | 541 +++++++++++++++++- .../ModulePipelineRunner.MergeAndTests.cs | 90 ++- .../ModulePipelineRunner.MissingAnalysis.cs | 68 ++- .../Services/ModulePipelineRunner.Plan.cs | 16 +- .../ModulePipelineRunner.RefreshOnlySync.cs | 23 +- .../ModulePipelineRunner.Run.Helpers.cs | 151 ++++- .../Services/ModulePipelineRunner.Run.cs | 19 +- Shared/SpectrePipelineSummaryWriter.cs | 146 +++++ 16 files changed, 1194 insertions(+), 114 deletions(-) create mode 100644 PowerForge.Tests/ModuleBuilderBinaryConflictAdvisoryTests.cs create mode 100644 PowerForge/Models/ModuleOwnerNote.cs diff --git a/Module/Build/Build-Module.ps1 b/Module/Build/Build-Module.ps1 index 6d56f2e5..a4b9c0b4 100644 --- a/Module/Build/Build-Module.ps1 +++ b/Module/Build/Build-Module.ps1 @@ -23,10 +23,6 @@ [string] $FailOnDiagnosticsSeverity ) -if (-not $JsonOnly) { - Remove-Item -Path (Join-Path $PSScriptRoot '../Lib') -Recurse -Force -ErrorAction SilentlyContinue -} - $repoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../..')) $moduleRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $artefactsRoot = Join-Path $moduleRoot 'Artefacts' @@ -55,32 +51,32 @@ if (-not $JsonOnly -and -not $NoDotnetBuild) { # installed/imported module in the caller session can shadow the current source changes. Get-Module -Name 'PSPublishModule' -All -ErrorAction SilentlyContinue | Remove-Module -Force -ErrorAction SilentlyContinue -$importPath = $null +# Clean the repo Lib payload only after unloading the module; otherwise Windows can keep +# stale PSPublishModule binaries locked and the delete silently fails. +if (-not $JsonOnly) { + Remove-Item -Path (Join-Path $PSScriptRoot '../Lib') -Recurse -Force -ErrorAction SilentlyContinue +} -# Json-only and no-dotnet-build flows should avoid importing the source manifest from Module\Lib. -# Once a PowerShell session loads those repo DLLs on Windows, later self-build attempts cannot -# refresh them in-place. Import the compiled binary module instead for config generation. -$preferBinaryImport = $JsonOnly -or $NoDotnetBuild +$importPath = $null -if ($preferBinaryImport) { - if (-not (Test-Path -LiteralPath $binaryModule)) { - if (Test-Path -LiteralPath $csproj) { - $i = [char]0x2139 # ℹ - Write-Host "$i Building PSPublishModule ($Configuration)" -ForegroundColor DarkGray - $buildOutput = & dotnet build $csproj -c $Configuration --nologo --verbosity quiet 2>&1 - if ($LASTEXITCODE -ne 0) { - $buildOutput | Out-Host - throw "dotnet build failed (exit $LASTEXITCODE)." - } +# Prefer the freshly built binary module for all PSPublishModule self-builds. Falling back to +# the source manifest is only a last resort when the binary output is unavailable. +if (-not (Test-Path -LiteralPath $binaryModule)) { + if (Test-Path -LiteralPath $csproj) { + $i = [char]0x2139 # ℹ + Write-Host "$i Building PSPublishModule ($Configuration)" -ForegroundColor DarkGray + $buildOutput = & dotnet build $csproj -c $Configuration --nologo --verbosity quiet 2>&1 + if ($LASTEXITCODE -ne 0) { + $buildOutput | Out-Host + throw "dotnet build failed (exit $LASTEXITCODE)." } } +} - if (Test-Path -LiteralPath $binaryModule) { - $importPath = $binaryModule - } +if (Test-Path -LiteralPath $binaryModule) { + $importPath = $binaryModule } elseif (Test-Path -LiteralPath $sourceLibRoot) { - # Keep the source-manifest path for regular repo builds where Module\Lib is intentionally populated. - # Build-ModuleSelf now prefers binary import to avoid in-place refreshes of loaded repo DLLs. + Write-Warning "Falling back to source manifest import because the compiled PSPublishModule binary was not found: $binaryModule" $importPath = $sourceManifest } else { if (-not (Test-Path -LiteralPath $binaryModule)) { @@ -104,6 +100,7 @@ if (-not $importPath) { throw "Invoke-ModuleBuild is not available. Ensure PSPublishModule.dll built and importable." } +Write-Verbose "Importing PSPublishModule from '$importPath'." Import-Module $importPath -Force $invokeModuleBuildCommand = Get-Command Invoke-ModuleBuild -ErrorAction SilentlyContinue diff --git a/PowerForge.Tests/ModuleBuilderBinaryConflictAdvisoryTests.cs b/PowerForge.Tests/ModuleBuilderBinaryConflictAdvisoryTests.cs new file mode 100644 index 00000000..e9e029a9 --- /dev/null +++ b/PowerForge.Tests/ModuleBuilderBinaryConflictAdvisoryTests.cs @@ -0,0 +1,88 @@ +using System; + +namespace PowerForge.Tests; + +public sealed class ModuleBuilderBinaryConflictAdvisoryTests +{ + [Fact] + public void BuildBinaryConflictAdvisorySummary_GroupsModulesAndAssemblies() + { + var result = new BinaryConflictDetectionResult( + powerShellEdition: "Desktop", + moduleRoot: @"C:\Repo\TestModule", + assemblyRootPath: @"C:\Repo\TestModule\Lib\Default", + assemblyRootRelativePath: @"Lib\Default", + issues: new[] + { + CreateIssue("System.Memory", "4.0.5.0", "AzureADHybridAuthenticationManagement", "2.4.71.0", "4.0.1.2", 1), + CreateIssue("System.Runtime.CompilerServices.Unsafe", "6.0.3.0", "AzureADHybridAuthenticationManagement", "2.4.71.0", "4.0.4.1", 1), + CreateIssue("Microsoft.Bcl.AsyncInterfaces", "9.0.0.0", "AzureADHybridAuthenticationManagement", "2.4.71.0", "9.0.0.9", -1), + CreateIssue("System.Memory", "4.0.5.0", "DomainDetective", "0.2.0.1", "4.0.1.2", 1), + CreateIssue("System.Memory", "4.0.5.0", "DomainDetective", "0.2.0.2", "4.0.6.0", -1), + CreateIssue("System.Memory", "4.0.5.0", "OtherModule", "1.0.0", "4.0.6.0", -1) + }, + summary: "6 conflicts across 2 module sources"); + + var advisory = ModuleBuilder.BuildBinaryConflictAdvisorySummary(result); + + Assert.Equal(3, advisory.DistinctPayloadAssemblies); + Assert.Equal(3, advisory.DistinctInstalledModules); + Assert.Equal(3, advisory.PayloadNewerConflicts); + Assert.Equal(3, advisory.PayloadOlderConflicts); + + var topModule = Assert.Single(advisory.TopModules, static module => module.ModuleLabel == "AzureADHybridAuthenticationManagement 2.4.71.0"); + Assert.Equal(3, topModule.ConflictCount); + Assert.Equal(3, topModule.DistinctAssemblies); + Assert.Equal(2, topModule.PayloadNewerCount); + Assert.Equal(1, topModule.PayloadOlderCount); + Assert.DoesNotContain(advisory.AllModules, static module => module.ModuleLabel == "DomainDetective 0.2.0.1"); + Assert.Contains(advisory.AllModules, static module => module.ModuleLabel == "DomainDetective 0.2.0.2"); + + var topAssembly = Assert.Single(advisory.TopAssemblies, static assembly => assembly.AssemblyLabel == "System.Memory 4.0.5.0"); + Assert.Equal(3, topAssembly.ConflictCount); + Assert.Equal(3, topAssembly.DistinctModules); + Assert.Equal(0, advisory.RemainingModuleCount); + Assert.Equal(0, advisory.RemainingAssemblyCount); + } + + [Fact] + public void BuildBinaryConflictAdvisorySummary_ExplainsWhenTheWarningIsActionable() + { + var result = new BinaryConflictDetectionResult( + powerShellEdition: "Core", + moduleRoot: @"C:\Repo\TestModule", + assemblyRootPath: @"C:\Repo\TestModule\Lib\Core", + assemblyRootRelativePath: @"Lib\Core", + issues: new[] + { + CreateIssue("System.Memory", "4.0.5.0", "LegacyModule", "1.0.0", "4.0.1.2", 1) + }, + summary: "1 conflict across 1 module source"); + + var advisory = ModuleBuilder.BuildBinaryConflictAdvisorySummary(result); + + Assert.Contains("Ignore unless you use this module together", advisory.Actionability, StringComparison.OrdinalIgnoreCase); + Assert.Contains("LegacyModule 1.0.0", advisory.Actionability, StringComparison.OrdinalIgnoreCase); + } + + private static BinaryConflictDetectionIssue CreateIssue( + string assemblyName, + string payloadAssemblyVersion, + string installedModuleName, + string installedModuleVersion, + string installedAssemblyVersion, + int versionComparison) + { + return new BinaryConflictDetectionIssue( + powerShellEdition: "Core", + assemblyName: assemblyName, + payloadAssemblyFileName: assemblyName + ".dll", + payloadAssemblyVersion: payloadAssemblyVersion, + installedModuleName: installedModuleName, + installedModuleVersion: installedModuleVersion, + installedAssemblyVersion: installedAssemblyVersion, + installedAssemblyRelativePath: installedModuleName + "/" + installedModuleVersion + "/bin/" + assemblyName + ".dll", + installedAssemblyPath: @"C:\Modules\" + installedModuleName + @"\" + installedModuleVersion + @"\bin\" + assemblyName + ".dll", + versionComparison: versionComparison); + } +} diff --git a/PowerForge.Tests/ModulePipelineRefreshManifestOnlyTests.cs b/PowerForge.Tests/ModulePipelineRefreshManifestOnlyTests.cs index 49c08039..d129c00f 100644 --- a/PowerForge.Tests/ModulePipelineRefreshManifestOnlyTests.cs +++ b/PowerForge.Tests/ModulePipelineRefreshManifestOnlyTests.cs @@ -177,7 +177,7 @@ public void Run_RefreshPSD1Only_UpdatesProjectRootManifest() } [Fact] - public void Run_NormalBuild_DoesNotUpdateProjectRootOutputs() + public void Run_NormalBuild_UpdatesProjectRootManifestOnly() { var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); try @@ -222,9 +222,9 @@ public void Run_NormalBuild_DoesNotUpdateProjectRootOutputs() Assert.True(File.Exists(projectManifest)); Assert.True(ManifestEditor.TryGetTopLevelString(projectManifest, "ModuleVersion", out var projectVersion)); - Assert.Equal("1.0.0", projectVersion); + Assert.Equal("2.0.0", projectVersion); Assert.True(ManifestEditor.TryGetTopLevelString(projectManifest, "Author", out var projectAuthor)); - Assert.Equal("OldAuthor", projectAuthor); + Assert.Equal("NewAuthor", projectAuthor); var projectPsm1 = Path.Combine(root.FullName, moduleName + ".psm1"); var projectPsm1Content = File.ReadAllText(projectPsm1); diff --git a/PowerForge/Models/ModuleBuildResult.cs b/PowerForge/Models/ModuleBuildResult.cs index fe79f9da..0f4b7bc4 100644 --- a/PowerForge/Models/ModuleBuildResult.cs +++ b/PowerForge/Models/ModuleBuildResult.cs @@ -14,14 +14,20 @@ public sealed class ModuleBuildResult /// Exports detected and written into the manifest. public ExportSet Exports { get; } + internal ModuleOwnerNote[] BuildNotes { get; } + /// /// Creates a new result instance. /// - public ModuleBuildResult(string stagingPath, string manifestPath, ExportSet exports) + public ModuleBuildResult( + string stagingPath, + string manifestPath, + ExportSet exports, + ModuleOwnerNote[]? buildNotes = null) { StagingPath = stagingPath; ManifestPath = manifestPath; Exports = exports; + BuildNotes = buildNotes ?? System.Array.Empty(); } } - diff --git a/PowerForge/Models/ModuleBuildSpec.cs b/PowerForge/Models/ModuleBuildSpec.cs index 13344964..21808110 100644 --- a/PowerForge/Models/ModuleBuildSpec.cs +++ b/PowerForge/Models/ModuleBuildSpec.cs @@ -78,6 +78,17 @@ public sealed class ModuleBuildSpec /// public string[] BinaryConflictSearchRoots { get; set; } = Array.Empty(); + /// + /// Declared module names that should be treated as higher-priority during binary conflict analysis. + /// This helps distinguish true module dependency overlap from unrelated locally installed modules. + /// + public string[] BinaryConflictPriorityModuleNames { get; set; } = Array.Empty(); + + /// + /// Optional project-root path used for writing human-readable binary conflict reports. + /// + public string? BinaryConflictReportRoot { get; set; } + /// /// Optional filters used to exclude copied binary libraries by package id, target key, relative path, or file name. /// diff --git a/PowerForge/Models/ModuleOwnerNote.cs b/PowerForge/Models/ModuleOwnerNote.cs new file mode 100644 index 00000000..7272bbaf --- /dev/null +++ b/PowerForge/Models/ModuleOwnerNote.cs @@ -0,0 +1,55 @@ +namespace PowerForge; + +/// +/// Importance of an owner-facing pipeline note. +/// +public enum ModuleOwnerNoteSeverity +{ + /// Informational note. + Info, + /// Warning note that may need action. + Warning +} + +/// +/// Structured note shown to module owners in the final pipeline summary. +/// +public sealed class ModuleOwnerNote +{ + /// Short note title. + public string Title { get; } + + /// Importance of the note. + public ModuleOwnerNoteSeverity Severity { get; } + + /// Short summary of what happened. + public string Summary { get; } + + /// Why the module owner should care. + public string WhyItMatters { get; } + + /// Suggested next step for the module owner. + public string NextStep { get; } + + /// Optional highlights shown as bullets. + public string[] Details { get; } + + /// + /// Creates a new owner note. + /// + public ModuleOwnerNote( + string title, + ModuleOwnerNoteSeverity severity, + string? summary = null, + string? whyItMatters = null, + string? nextStep = null, + string[]? details = null) + { + Title = title ?? string.Empty; + Severity = severity; + Summary = summary ?? string.Empty; + WhyItMatters = whyItMatters ?? string.Empty; + NextStep = nextStep ?? string.Empty; + Details = details ?? System.Array.Empty(); + } +} diff --git a/PowerForge/Models/ModulePipelineResult.cs b/PowerForge/Models/ModulePipelineResult.cs index 9128feeb..c812230a 100644 --- a/PowerForge/Models/ModulePipelineResult.cs +++ b/PowerForge/Models/ModulePipelineResult.cs @@ -115,6 +115,11 @@ public sealed class ModulePipelineResult /// public ArtefactBuildResult[] ArtefactResults { get; } + /// + /// Owner-facing notes captured during the run for the final summary. + /// + public ModuleOwnerNote[] OwnerNotes { get; } + /// /// Creates a new result instance. /// @@ -140,7 +145,8 @@ public ModulePipelineResult( CheckStatus? projectRootFileConsistencyStatus = null, ProjectConversionResult? projectRootFileConsistencyEncodingFix = null, ProjectConversionResult? projectRootFileConsistencyLineEndingFix = null, - ModuleSigningResult? signingResult = null) + ModuleSigningResult? signingResult = null, + ModuleOwnerNote[]? ownerNotes = null) { Plan = plan; BuildResult = buildResult; @@ -164,5 +170,6 @@ public ModulePipelineResult( PublishResults = publishResults ?? Array.Empty(); ArtefactResults = artefactResults ?? Array.Empty(); SigningResult = signingResult; + OwnerNotes = ownerNotes ?? Array.Empty(); } } diff --git a/PowerForge/Services/ModuleBuildPipeline.cs b/PowerForge/Services/ModuleBuildPipeline.cs index f2cc0698..d0f5cb49 100644 --- a/PowerForge/Services/ModuleBuildPipeline.cs +++ b/PowerForge/Services/ModuleBuildPipeline.cs @@ -20,11 +20,19 @@ internal sealed class StagingResult { public string SourcePath { get; } public string StagingPath { get; } - - public StagingResult(string sourcePath, string stagingPath) + public int NormalizedLineEndingsCount { get; } + public int LineEndingNormalizationErrors { get; } + + public StagingResult( + string sourcePath, + string stagingPath, + int normalizedLineEndingsCount, + int lineEndingNormalizationErrors) { SourcePath = sourcePath; StagingPath = stagingPath; + NormalizedLineEndingsCount = normalizedLineEndingsCount; + LineEndingNormalizationErrors = lineEndingNormalizationErrors; } } @@ -79,9 +87,13 @@ internal StagingResult StageToStaging(ModuleBuildSpec spec) _logger.Info($"Staging module '{spec.Name}' from '{source}' to '{staging}'"); CopyDirectoryFiltered(source, staging, excluded, excludedFiles); - NormalizeMixedPowerShellLineEndings(staging, excluded, excludedFiles); + var normalization = NormalizeMixedPowerShellLineEndings(staging, excluded, excludedFiles); - return new StagingResult(source, staging); + return new StagingResult( + source, + staging, + normalization.Converted, + normalization.Errors); } internal ModuleBuildResult BuildInStaging(ModuleBuildSpec spec, string stagingPath) @@ -95,7 +107,7 @@ internal ModuleBuildResult BuildInStaging(ModuleBuildSpec spec, string stagingPa var builder = new ModuleBuilder(_logger); var tfms = spec.Frameworks is { Length: > 0 } ? spec.Frameworks : new[] { "net472", "net8.0" }; - builder.BuildInPlace(new ModuleBuilder.Options + var buildNotes = builder.BuildInPlace(new ModuleBuilder.Options { ProjectRoot = staging, ModuleName = spec.Name, @@ -112,6 +124,8 @@ internal ModuleBuildResult BuildInStaging(ModuleBuildSpec spec, string stagingPa ExportAssemblies = spec.ExportAssemblies ?? Array.Empty(), DisableBinaryCmdletScan = spec.DisableBinaryCmdletScan, BinaryConflictSearchRoots = spec.BinaryConflictSearchRoots ?? Array.Empty(), + BinaryConflictPriorityModuleNames = spec.BinaryConflictPriorityModuleNames ?? Array.Empty(), + BinaryConflictReportRoot = spec.BinaryConflictReportRoot, ExcludeLibraryFilter = spec.ExcludeLibraryFilter ?? Array.Empty(), DoNotCopyLibrariesRecursively = spec.DoNotCopyLibrariesRecursively, }); @@ -133,7 +147,7 @@ internal ModuleBuildResult BuildInStaging(ModuleBuildSpec spec, string stagingPa _logger.Info("RefreshPSD1Only enabled: skipping bootstrapper/libraries regeneration."); } - return new ModuleBuildResult(staging, psd1, exports); + return new ModuleBuildResult(staging, psd1, exports, buildNotes); } /// @@ -271,7 +285,7 @@ private static ExportSet ReadExportsFromManifest(string psd1Path) ReadStringOrArray(psd1Path, "CmdletsToExport"), ReadStringOrArray(psd1Path, "AliasesToExport")); - private void NormalizeMixedPowerShellLineEndings(string stagingPath, ISet excludedDirectoryNames, ISet excludedFileNames) + private (int Converted, int Errors) NormalizeMixedPowerShellLineEndings(string stagingPath, ISet excludedDirectoryNames, ISet excludedFileNames) { try { @@ -303,10 +317,12 @@ private void NormalizeMixedPowerShellLineEndings(string stagingPath, ISet 0) _logger.Warn($"Failed to normalize line endings for {result.Errors} staged file(s)."); + return (result.Converted, result.Errors); } catch (Exception ex) { _logger.Warn($"Mixed line-ending normalization in staging failed: {ex.Message}"); + return (0, 1); } } diff --git a/PowerForge/Services/ModuleBuilder.cs b/PowerForge/Services/ModuleBuilder.cs index 1bc174ec..a87335a2 100644 --- a/PowerForge/Services/ModuleBuilder.cs +++ b/PowerForge/Services/ModuleBuilder.cs @@ -75,6 +75,16 @@ public sealed class Options /// public IReadOnlyList BinaryConflictSearchRoots { get; set; } = Array.Empty(); + /// + /// Declared module names that should be treated as higher-priority during binary conflict analysis. + /// + public IReadOnlyList BinaryConflictPriorityModuleNames { get; set; } = Array.Empty(); + + /// + /// Optional project-root path used for writing human-readable binary conflict reports. + /// + public string? BinaryConflictReportRoot { get; set; } + /// /// Optional filters used to exclude copied binary libraries by package id, target key, relative path, or file name. /// @@ -90,7 +100,7 @@ public sealed class Options /// Builds the module layout in-place under without installing it. /// /// Build options. - public void BuildInPlace(Options opts) + public ModuleOwnerNote[] BuildInPlace(Options opts) { if (string.IsNullOrWhiteSpace(opts.ProjectRoot) || !Directory.Exists(opts.ProjectRoot)) throw new DirectoryNotFoundException($"Project root not found: {opts.ProjectRoot}"); @@ -182,7 +192,7 @@ public void BuildInPlace(Options opts) } } - WarnOnInstalledBinaryConflicts(opts); + var buildNotes = WarnOnInstalledBinaryConflicts(opts); // 2) Manifest generation var psd1 = Path.Combine(opts.ProjectRoot, $"{opts.ModuleName}.psd1"); @@ -264,6 +274,7 @@ public void BuildInPlace(Options opts) } BuildServices.SetManifestExports(psd1, functions: functionsToSet, cmdlets: cmdletsToSet, aliases: aliasesToSet); + return buildNotes; } /// @@ -273,7 +284,7 @@ public void BuildInPlace(Options opts) /// Installation result including resolved version and installed paths. public ModuleInstallerResult Build(Options opts) { - BuildInPlace(opts); + _ = BuildInPlace(opts); return BuildServices.InstallVersioned( stagingPath: opts.ProjectRoot, moduleName: opts.ModuleName, @@ -457,18 +468,26 @@ private static ISet ResolveExportAssemblyFileNames(string moduleName, IR return fileNames; } - private void WarnOnInstalledBinaryConflicts(Options opts) + private ModuleOwnerNote[] WarnOnInstalledBinaryConflicts(Options opts) { var compatiblePSEditions = (opts.CompatiblePSEditions ?? Array.Empty()) .Where(static s => !string.IsNullOrWhiteSpace(s)) .Select(static s => s.Trim()) .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); + var priorityModuleNames = (opts.BinaryConflictPriorityModuleNames ?? Array.Empty()) + .Where(static name => !string.IsNullOrWhiteSpace(name)) + .Select(static name => name.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); if (compatiblePSEditions.Length == 0) compatiblePSEditions = new[] { "Core" }; var detector = new BinaryConflictDetectionService(_logger); + var notes = new List(); + var editionStatuses = new List<(string Edition, bool HasConflicts)>(); + var advisories = new List<(BinaryConflictDetectionResult Result, BinaryConflictAdvisorySummary Advisory, string? ReportPath)>(); foreach (var edition in compatiblePSEditions) { var result = detector.Analyze( @@ -477,27 +496,397 @@ private void WarnOnInstalledBinaryConflicts(Options opts) currentModuleName: opts.ModuleName, searchRoots: opts.BinaryConflictSearchRoots); if (!result.HasConflicts) + { + editionStatuses.Add((result.PowerShellEdition, false)); continue; + } + var advisory = BuildBinaryConflictAdvisorySummary(result, priorityModuleNames); + editionStatuses.Add((result.PowerShellEdition, true)); + var reportPath = WriteBinaryConflictReport(opts.BinaryConflictReportRoot, advisory, result); _logger.Warn($"Binary conflict advisory ({result.PowerShellEdition}): {result.Summary}."); + _logger.Warn($" Scope: {BuildBinaryConflictScopeText(advisory)}"); - foreach (var issue in result.Issues.Take(3)) + foreach (var module in advisory.AllModules) { - var moduleLabel = string.IsNullOrWhiteSpace(issue.InstalledModuleVersion) + _logger.Warn(" " + BuildBinaryConflictModuleSummaryLine(module, includeModuleLabel: true)); + } + + if (!string.IsNullOrWhiteSpace(advisory.Actionability)) + _logger.Warn($" Check: {advisory.Actionability}"); + if (!string.IsNullOrWhiteSpace(reportPath)) + _logger.Warn($" Report: {reportPath}"); + + advisories.Add((result, advisory, reportPath)); + } + + if (advisories.Count == 0) + { + if (editionStatuses.Count > 0) + { + notes.Add(new ModuleOwnerNote( + "Binary Conflicts", + ModuleOwnerNoteSeverity.Info, + summary: BuildBinaryConflictEditionStatusText(editionStatuses), + details: Array.Empty())); + } + + return notes.ToArray(); + } + + var editionStatusText = BuildBinaryConflictEditionStatusText(editionStatuses); + foreach (var entry in advisories) + { + notes.Add(new ModuleOwnerNote( + $"Binary Conflicts ({entry.Result.PowerShellEdition})", + ModuleOwnerNoteSeverity.Warning, + summary: editionStatusText, + whyItMatters: BuildBinaryConflictWhyItMatters(entry.Advisory), + nextStep: BuildBinaryConflictNextStep(entry.Advisory, entry.ReportPath), + details: BuildBinaryConflictOwnerDetails(entry.Advisory, entry.ReportPath))); + } + + return notes.ToArray(); + } + + private static string BuildBinaryConflictEditionStatusText(IReadOnlyList<(string Edition, bool HasConflicts)> statuses) + { + if (statuses is null || statuses.Count == 0) + return string.Empty; + + return string.Join("; ", statuses.Select(static entry => + $"{entry.Edition}: {(entry.HasConflicts ? "conflicts found" : "no conflicts")}")); + } + + private static string BuildDeclaredDependencyModulesText(BinaryConflictAdvisorySummary advisory) + { + if (advisory.PriorityModuleLabels.Length > 0) + return "Declared dependency modules involved: " + string.Join(", ", advisory.PriorityModuleLabels) + "."; + + return "Declared dependency modules involved: none."; + } + + internal static BinaryConflictAdvisorySummary BuildBinaryConflictAdvisorySummary( + BinaryConflictDetectionResult result, + IReadOnlyList? priorityModuleNames = null) + { + if (result is null) + throw new ArgumentNullException(nameof(result)); + + var issues = result.Issues ?? Array.Empty(); + var priorityNames = (priorityModuleNames ?? Array.Empty()) + .Where(static name => !string.IsNullOrWhiteSpace(name)) + .Select(static name => name.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + var payloadNewer = issues.Count(static issue => issue.VersionComparison > 0); + var payloadOlder = issues.Count(static issue => issue.VersionComparison < 0); + + var latestModuleIssues = issues + .GroupBy(static issue => issue.InstalledModuleName, StringComparer.OrdinalIgnoreCase) + .SelectMany(group => + { + var selectedVersionGroup = group + .GroupBy(static issue => issue.InstalledModuleVersion, StringComparer.OrdinalIgnoreCase) + .OrderByDescending(static versionGroup => ParseModuleVersionOrNull(versionGroup.Key), VersionComparer.Instance) + .ThenByDescending(static versionGroup => versionGroup.Key ?? string.Empty, StringComparer.OrdinalIgnoreCase) + .First(); + return selectedVersionGroup; + }) + .ToArray(); + + var topModules = latestModuleIssues + .GroupBy( + static issue => new + { + issue.InstalledModuleName, + issue.InstalledModuleVersion + }) + .Select(group => + { + var moduleName = group.Key.InstalledModuleName; + var moduleVersion = group.Key.InstalledModuleVersion; + var label = string.IsNullOrWhiteSpace(moduleVersion) + ? moduleName + : moduleName + " " + moduleVersion; + return new BinaryConflictModuleSummary( + moduleLabel: label, + isPriority: priorityNames.Contains(moduleName, StringComparer.OrdinalIgnoreCase), + conflictCount: group.Count(), + distinctAssemblies: group + .Select(static issue => issue.AssemblyName) + .Where(static name => !string.IsNullOrWhiteSpace(name)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Count(), + topAssemblies: group + .Select(static issue => issue.AssemblyName) + .Where(static name => !string.IsNullOrWhiteSpace(name)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(static name => name, StringComparer.OrdinalIgnoreCase) + .Take(3) + .ToArray(), + examples: group + .GroupBy(static issue => issue.AssemblyName, StringComparer.OrdinalIgnoreCase) + .Select(static assemblyGroup => assemblyGroup + .OrderByDescending(static issue => Math.Abs(issue.VersionComparison)) + .ThenBy(static issue => issue.AssemblyName, StringComparer.OrdinalIgnoreCase) + .First()) + .OrderBy(static issue => issue.AssemblyName, StringComparer.OrdinalIgnoreCase) + .Take(2) + .Select(static issue => new BinaryConflictExampleSummary( + issue.AssemblyName, + issue.PayloadAssemblyVersion, + issue.InstalledAssemblyVersion)) + .ToArray(), + mismatches: group + .GroupBy(static issue => issue.AssemblyName, StringComparer.OrdinalIgnoreCase) + .Select(static assemblyGroup => assemblyGroup + .OrderBy(static issue => issue.AssemblyName, StringComparer.OrdinalIgnoreCase) + .First()) + .OrderBy(static issue => issue.AssemblyName, StringComparer.OrdinalIgnoreCase) + .Select(static issue => new BinaryConflictExampleSummary( + issue.AssemblyName, + issue.PayloadAssemblyVersion, + issue.InstalledAssemblyVersion)) + .ToArray(), + payloadNewerCount: group.Count(static issue => issue.VersionComparison > 0), + payloadOlderCount: group.Count(static issue => issue.VersionComparison < 0)); + }) + .OrderByDescending(static item => item.ConflictCount) + .ThenByDescending(static item => item.DistinctAssemblies) + .ThenBy(static item => item.ModuleLabel, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var topAssemblies = latestModuleIssues + .GroupBy( + static issue => new + { + issue.AssemblyName, + issue.PayloadAssemblyVersion + }) + .Select(group => new BinaryConflictAssemblySummary( + assemblyLabel: $"{group.Key.AssemblyName} {group.Key.PayloadAssemblyVersion}", + conflictCount: group.Count(), + distinctModules: group + .Select(static issue => string.IsNullOrWhiteSpace(issue.InstalledModuleVersion) + ? issue.InstalledModuleName + : issue.InstalledModuleName + " " + issue.InstalledModuleVersion) + .Where(static label => !string.IsNullOrWhiteSpace(label)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Count())) + .OrderByDescending(static item => item.ConflictCount) + .ThenByDescending(static item => item.DistinctModules) + .ThenBy(static item => item.AssemblyLabel, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var actionability = BuildBinaryConflictActionability( + payloadNewer, + payloadOlder, + topModules.Length > 0 ? topModules[0].ModuleLabel : null); + + return new BinaryConflictAdvisorySummary( + powerShellEdition: result.PowerShellEdition, + distinctPayloadAssemblies: issues + .Select(static issue => issue.AssemblyName) + .Where(static name => !string.IsNullOrWhiteSpace(name)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Count(), + distinctInstalledModules: latestModuleIssues + .Select(static issue => string.IsNullOrWhiteSpace(issue.InstalledModuleVersion) + ? issue.InstalledModuleName + : issue.InstalledModuleName + " " + issue.InstalledModuleVersion) + .Where(static label => !string.IsNullOrWhiteSpace(label)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Count(), + payloadNewerConflicts: payloadNewer, + payloadOlderConflicts: payloadOlder, + allModules: topModules, + topModules: topModules.Take(3).ToArray(), + remainingModuleCount: Math.Max(0, topModules.Length - 3), + topAssemblies: topAssemblies.Take(3).ToArray(), + remainingAssemblyCount: Math.Max(0, topAssemblies.Length - 3), + priorityModuleLabels: latestModuleIssues + .Where(issue => priorityNames.Contains(issue.InstalledModuleName, StringComparer.OrdinalIgnoreCase)) + .Select(static issue => string.IsNullOrWhiteSpace(issue.InstalledModuleVersion) ? issue.InstalledModuleName - : issue.InstalledModuleName + " " + issue.InstalledModuleVersion; - var relation = issue.VersionComparison > 0 ? "older" : "newer"; + : issue.InstalledModuleName + " " + issue.InstalledModuleVersion) + .Where(static label => !string.IsNullOrWhiteSpace(label)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(static label => label, StringComparer.OrdinalIgnoreCase) + .ToArray(), + actionability: actionability); + } + + private static string BuildBinaryConflictActionability(int payloadNewer, int payloadOlder, string? primaryModuleLabel) + { + if (payloadNewer == 0 && payloadOlder == 0) + return "Only matters when the listed modules are imported into the same session."; + + return string.IsNullOrWhiteSpace(primaryModuleLabel) + ? "Ignore unless you use this module together with one of the listed installed modules." + : $"Ignore unless you use this module together with {primaryModuleLabel} or one of the other listed installed modules."; + } + + private static Version? ParseModuleVersionOrNull(string? value) + => Version.TryParse(value, out var parsed) ? parsed : null; + + private static string BuildBinaryConflictWhyItMatters(BinaryConflictAdvisorySummary advisory) + { + var sessionLabel = BuildBinaryConflictSessionLabel(advisory.PowerShellEdition); + + if (advisory.PriorityModuleLabels.Length > 0) + { + return $"Only matters if this module and one of those declared modules are loaded into the same {sessionLabel} session."; + } + + return $"Only matters if this module and one of the modules below are loaded into the same {sessionLabel} session."; + } + + private static string BuildBinaryConflictNextStep(BinaryConflictAdvisorySummary advisory, string? reportPath) + { + if (advisory.PriorityModuleLabels.Length > 0) + { + return string.IsNullOrWhiteSpace(reportPath) + ? $"If you use those declared modules together, check the exact assembly/version pairs first: {string.Join(", ", advisory.PriorityModuleLabels)}." + : $"If you use those declared modules together, open the full report first. It shows the exact assembly/version pairs for: {string.Join(", ", advisory.PriorityModuleLabels)}."; + } + + return string.IsNullOrWhiteSpace(reportPath) + ? "Ignore if you never load those modules together. Otherwise check the exact assembly/version pairs before testing import order." + : "Ignore if you never load those modules together. Otherwise open the full report for the exact assembly/version pairs, then test import order only for the modules you actually use together."; + } - _logger.Warn( - $" {issue.AssemblyName} payload {issue.PayloadAssemblyVersion} may conflict with {moduleLabel} " + - $"({relation} installed version {issue.InstalledAssemblyVersion})."); + private static string[] BuildBinaryConflictOwnerDetails(BinaryConflictAdvisorySummary advisory, string? reportPath) + { + var details = new List(); + details.Add(BuildDeclaredDependencyModulesText(advisory)); + details.Add("Installed modules below already keep only the newest installed version per module name."); + + if (advisory.AllModules.Length > 0) + { + foreach (var module in advisory.AllModules) + details.Add(BuildBinaryConflictModuleSummaryLine(module, includeModuleLabel: true)); + } + + if (!string.IsNullOrWhiteSpace(reportPath)) + details.Add("Full report: " + reportPath!.Trim()); + + return details.ToArray(); + } + + private string? WriteBinaryConflictReport( + string? reportRoot, + BinaryConflictAdvisorySummary advisory, + BinaryConflictDetectionResult result) + { + if (string.IsNullOrWhiteSpace(reportRoot)) + return null; + + try + { + var root = Path.GetFullPath(reportRoot); + var reportsDirectory = Path.Combine(root, "Artefacts", "Reports"); + Directory.CreateDirectory(reportsDirectory); + var fileName = $"BinaryConflicts.{result.PowerShellEdition}.txt"; + var path = Path.Combine(reportsDirectory, fileName); + + var lines = new List + { + $"Binary conflict report for {result.PowerShellEdition}", + $"Summary: {result.Issues.Length} assembly-version mismatches across {advisory.AllModules.Length} installed module(s).", + BuildDeclaredDependencyModulesText(advisory), + "Installed modules below already keep only the newest installed version per module name.", + $"Why this matters: {BuildBinaryConflictWhyItMatters(advisory)}", + string.Empty + }; + + foreach (var module in advisory.AllModules) + { + lines.Add(module.ModuleLabel); + lines.Add($" Shared assemblies: {module.DistinctAssemblies}"); + lines.Add($" Version direction: {BuildBinaryConflictVersionDirectionText(module)}"); + lines.Add($" Suggested check: {BuildBinaryConflictModuleCheckText(module)}"); + lines.Add(" Mismatches:"); + foreach (var mismatch in module.Mismatches) + lines.Add($" - {mismatch.AssemblyName}: ours {mismatch.PayloadAssemblyVersion}, theirs {mismatch.InstalledAssemblyVersion} ({BuildBinaryConflictMismatchDirectionText(mismatch)})"); + lines.Add(string.Empty); } - if (result.Issues.Length > 3) - _logger.Warn($" +{result.Issues.Length - 3} more conflict(s) detected."); + File.WriteAllLines(path, lines); + return path; + } + catch (Exception ex) + { + _logger.Verbose($"Failed to write binary conflict report. {ex.Message}"); + return null; } } + private static string BuildBinaryConflictScopeText(BinaryConflictAdvisorySummary advisory) + { + return BuildDeclaredDependencyModulesText(advisory); + } + + private static string BuildBinaryConflictSessionLabel(string? powerShellEdition) + { + if (string.Equals(powerShellEdition, "Desktop", StringComparison.OrdinalIgnoreCase)) + return "Windows PowerShell/Desktop"; + if (string.Equals(powerShellEdition, "Core", StringComparison.OrdinalIgnoreCase)) + return "PowerShell/Core"; + + return string.IsNullOrWhiteSpace(powerShellEdition) ? "PowerShell" : powerShellEdition!; + } + + private static string BuildBinaryConflictVersionDirectionText(BinaryConflictModuleSummary module) + { + return module.PayloadNewerCount > 0 && module.PayloadOlderCount > 0 + ? "mixed newer/older versions on different assemblies" + : module.PayloadNewerCount > 0 + ? "our module is newer on every listed assembly" + : module.PayloadOlderCount > 0 + ? "the installed module is newer on every listed assembly" + : "different versions"; + } + + private static string BuildBinaryConflictModuleCheckText(BinaryConflictModuleSummary module) + { + return module.PayloadNewerCount > 0 && module.PayloadOlderCount > 0 + ? "If you use both modules together, test both import orders." + : module.PayloadNewerCount > 0 + ? "If you use both modules together, import that module first, then this one." + : module.PayloadOlderCount > 0 + ? "If you use both modules together, import this module first, then that module." + : "If you use both modules together, test both import orders."; + } + + private static string BuildBinaryConflictMismatchDirectionText(BinaryConflictExampleSummary mismatch) + { + var payload = ParseModuleVersionOrNull(mismatch.PayloadAssemblyVersion); + var installed = ParseModuleVersionOrNull(mismatch.InstalledAssemblyVersion); + if (payload is null || installed is null) + return "versions differ"; + + var comparison = payload.CompareTo(installed); + if (comparison > 0) + return "ours newer"; + if (comparison < 0) + return "theirs newer"; + + return "versions differ"; + } + + private static string BuildBinaryConflictModuleSummaryLine(BinaryConflictModuleSummary module, bool includeModuleLabel) + { + var prefix = includeModuleLabel ? module.ModuleLabel + ": " : string.Empty; + return $"{prefix}{module.DistinctAssemblies} shared assemblies differ; {BuildBinaryConflictVersionDirectionText(module)}."; + } + + private static string BuildBinaryConflictModuleOwnerDetail(BinaryConflictModuleSummary module, bool includeModuleLabel) + { + var prefix = includeModuleLabel ? module.ModuleLabel + ": " : string.Empty; + return $"{prefix}{module.DistinctAssemblies} shared assemblies differ; {BuildBinaryConflictVersionDirectionText(module)}; {BuildBinaryConflictModuleCheckText(module)}"; + } + private PublishCopyPlan CreateCopyPlan(string publishDir, string tfm, PublishCopyOptions options) { var plan = TryCreateCopyPlanFromDeps(publishDir, tfm, options); @@ -873,4 +1262,130 @@ private static string[] ResolveExportAssemblies(string projectRoot, string modul return list.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); } + + internal sealed class BinaryConflictAdvisorySummary + { + internal string PowerShellEdition { get; } + internal int DistinctPayloadAssemblies { get; } + internal int DistinctInstalledModules { get; } + internal int PayloadNewerConflicts { get; } + internal int PayloadOlderConflicts { get; } + internal BinaryConflictModuleSummary[] AllModules { get; } + internal BinaryConflictModuleSummary[] TopModules { get; } + internal int RemainingModuleCount { get; } + internal BinaryConflictAssemblySummary[] TopAssemblies { get; } + internal int RemainingAssemblyCount { get; } + internal string[] PriorityModuleLabels { get; } + internal string Actionability { get; } + + internal BinaryConflictAdvisorySummary( + string powerShellEdition, + int distinctPayloadAssemblies, + int distinctInstalledModules, + int payloadNewerConflicts, + int payloadOlderConflicts, + BinaryConflictModuleSummary[] allModules, + BinaryConflictModuleSummary[] topModules, + int remainingModuleCount, + BinaryConflictAssemblySummary[] topAssemblies, + int remainingAssemblyCount, + string[] priorityModuleLabels, + string actionability) + { + PowerShellEdition = powerShellEdition ?? string.Empty; + DistinctPayloadAssemblies = distinctPayloadAssemblies; + DistinctInstalledModules = distinctInstalledModules; + PayloadNewerConflicts = payloadNewerConflicts; + PayloadOlderConflicts = payloadOlderConflicts; + AllModules = allModules ?? Array.Empty(); + TopModules = topModules ?? Array.Empty(); + RemainingModuleCount = remainingModuleCount; + TopAssemblies = topAssemblies ?? Array.Empty(); + RemainingAssemblyCount = remainingAssemblyCount; + PriorityModuleLabels = priorityModuleLabels ?? Array.Empty(); + Actionability = actionability ?? string.Empty; + } + } + + internal sealed class BinaryConflictModuleSummary + { + internal string ModuleLabel { get; } + internal bool IsPriority { get; } + internal int ConflictCount { get; } + internal int DistinctAssemblies { get; } + internal string[] TopAssemblies { get; } + internal BinaryConflictExampleSummary[] Examples { get; } + internal BinaryConflictExampleSummary[] Mismatches { get; } + internal int PayloadNewerCount { get; } + internal int PayloadOlderCount { get; } + + internal BinaryConflictModuleSummary( + string moduleLabel, + bool isPriority, + int conflictCount, + int distinctAssemblies, + string[] topAssemblies, + BinaryConflictExampleSummary[] examples, + BinaryConflictExampleSummary[] mismatches, + int payloadNewerCount, + int payloadOlderCount) + { + ModuleLabel = moduleLabel ?? string.Empty; + IsPriority = isPriority; + ConflictCount = conflictCount; + DistinctAssemblies = distinctAssemblies; + TopAssemblies = topAssemblies ?? Array.Empty(); + Examples = examples ?? Array.Empty(); + Mismatches = mismatches ?? Array.Empty(); + PayloadNewerCount = payloadNewerCount; + PayloadOlderCount = payloadOlderCount; + } + } + + private sealed class VersionComparer : IComparer + { + internal static VersionComparer Instance { get; } = new(); + + public int Compare(Version? x, Version? y) + { + if (ReferenceEquals(x, y)) return 0; + if (x is null) return -1; + if (y is null) return 1; + return x.CompareTo(y); + } + } + + internal sealed class BinaryConflictExampleSummary + { + internal string AssemblyName { get; } + internal string PayloadAssemblyVersion { get; } + internal string InstalledAssemblyVersion { get; } + + internal BinaryConflictExampleSummary( + string assemblyName, + string payloadAssemblyVersion, + string installedAssemblyVersion) + { + AssemblyName = assemblyName ?? string.Empty; + PayloadAssemblyVersion = payloadAssemblyVersion ?? string.Empty; + InstalledAssemblyVersion = installedAssemblyVersion ?? string.Empty; + } + } + + internal sealed class BinaryConflictAssemblySummary + { + internal string AssemblyLabel { get; } + internal int ConflictCount { get; } + internal int DistinctModules { get; } + + internal BinaryConflictAssemblySummary( + string assemblyLabel, + int conflictCount, + int distinctModules) + { + AssemblyLabel = assemblyLabel ?? string.Empty; + ConflictCount = conflictCount; + DistinctModules = distinctModules; + } + } } diff --git a/PowerForge/Services/ModulePipelineRunner.MergeAndTests.cs b/PowerForge/Services/ModulePipelineRunner.MergeAndTests.cs index d0c83f18..0fa562e0 100644 --- a/PowerForge/Services/ModulePipelineRunner.MergeAndTests.cs +++ b/PowerForge/Services/ModulePipelineRunner.MergeAndTests.cs @@ -10,10 +10,10 @@ namespace PowerForge; public sealed partial class ModulePipelineRunner { - private bool ApplyMerge(ModulePipelinePlan plan, ModuleBuildResult buildResult) - { - if (plan is null || buildResult is null) return false; - if (!plan.MergeModule && !plan.MergeMissing) return false; + private MergeExecutionResult ApplyMerge(ModulePipelinePlan plan, ModuleBuildResult buildResult) + { + if (plan is null || buildResult is null) return MergeExecutionResult.None; + if (!plan.MergeModule && !plan.MergeMissing) return MergeExecutionResult.None; var mergeInfo = BuildMergeSources( buildResult.StagingPath, @@ -21,11 +21,22 @@ private bool ApplyMerge(ModulePipelinePlan plan, ModuleBuildResult buildResult) plan.Information, buildResult.Exports, fixRelativePaths: !plan.DoNotAttemptToFixRelativePaths); - if (!mergeInfo.HasScripts && !File.Exists(mergeInfo.Psm1Path)) - { - _logger.Warn("Merge requested but no script sources or PSM1 file were found."); - return false; - } + if (!mergeInfo.HasScripts && !File.Exists(mergeInfo.Psm1Path)) + { + _logger.Warn("Merge requested but no script sources or PSM1 file were found."); + return new MergeExecutionResult( + mergedModule: false, + usedExistingPsm1: false, + retainedBootstrapperBecauseBinaryOutputsDetected: false, + requiredModules: plan.RequiredModules ?? Array.Empty(), + approvedModules: plan.ApprovedModules ?? Array.Empty(), + dependentModules: Array.Empty(), + topLevelInlinedFunctions: 0, + totalInlinedFunctions: 0, + scriptFilesDetected: 0, + hasBinaryOutputs: mergeInfo.HasLib, + hasScriptSources: false); + } string? analysisCode = mergeInfo.HasScripts ? mergeInfo.MergedScriptContent : null; string? analysisPath = mergeInfo.HasScripts ? null : mergeInfo.Psm1Path; @@ -44,19 +55,23 @@ private bool ApplyMerge(ModulePipelinePlan plan, ModuleBuildResult buildResult) ValidateMissingFunctions(missingReport, plan, dependentRequiredModules); } - var mergedModule = false; - if (plan.MergeModule) - { - if (mergeInfo.HasLib) - { - _logger.Warn("MergeModuleOnBuild requested but binary outputs were detected. Keeping bootstrapper PSM1."); - } - else if (!mergeInfo.HasScripts) - { - _logger.Warn("MergeModuleOnBuild requested but no script sources were found. Skipping merge."); - } - else - { + var mergedModule = false; + var retainedBootstrapperBecauseBinaryOutputsDetected = false; + var usedExistingPsm1 = false; + if (plan.MergeModule) + { + if (mergeInfo.HasLib) + { + _logger.Warn("MergeModuleOnBuild requested but binary outputs were detected. Keeping bootstrapper PSM1."); + retainedBootstrapperBecauseBinaryOutputsDetected = true; + } + else if (!mergeInfo.HasScripts) + { + _logger.Warn("MergeModuleOnBuild requested but no script sources were found. Skipping merge."); + usedExistingPsm1 = File.Exists(mergeInfo.Psm1Path); + } + else + { if (File.Exists(mergeInfo.Psm1Path)) { try @@ -80,15 +95,28 @@ private bool ApplyMerge(ModulePipelinePlan plan, ModuleBuildResult buildResult) } } - if (!mergedModule && plan.MergeMissing && missingReport?.Functions is { Length: > 0 } && File.Exists(mergeInfo.Psm1Path)) - { - var existing = File.ReadAllText(mergeInfo.Psm1Path); - var merged = PrependFunctions(missingReport.Functions, existing); - WriteMergedPsm1(mergeInfo.Psm1Path, merged); - } - - return mergedModule; - } + if (!mergedModule && plan.MergeMissing && missingReport?.Functions is { Length: > 0 } && File.Exists(mergeInfo.Psm1Path)) + { + var existing = File.ReadAllText(mergeInfo.Psm1Path); + var merged = PrependFunctions(missingReport.Functions, existing); + WriteMergedPsm1(mergeInfo.Psm1Path, merged); + } + + usedExistingPsm1 |= !mergeInfo.HasScripts && File.Exists(mergeInfo.Psm1Path); + + return new MergeExecutionResult( + mergedModule: mergedModule, + usedExistingPsm1: usedExistingPsm1, + retainedBootstrapperBecauseBinaryOutputsDetected: retainedBootstrapperBecauseBinaryOutputsDetected, + requiredModules: plan.RequiredModules ?? Array.Empty(), + approvedModules: plan.ApprovedModules ?? Array.Empty(), + dependentModules: dependentRequiredModules, + topLevelInlinedFunctions: missingReport?.FunctionsTopLevelOnly?.Length ?? 0, + totalInlinedFunctions: missingReport?.Functions?.Length ?? 0, + scriptFilesDetected: mergeInfo.ScriptFiles.Length, + hasBinaryOutputs: mergeInfo.HasLib, + hasScriptSources: mergeInfo.HasScripts); + } private void ApplyPlaceholders(ModulePipelinePlan plan, ModuleBuildResult buildResult) { diff --git a/PowerForge/Services/ModulePipelineRunner.MissingAnalysis.cs b/PowerForge/Services/ModulePipelineRunner.MissingAnalysis.cs index d107c49a..75731267 100644 --- a/PowerForge/Services/ModulePipelineRunner.MissingAnalysis.cs +++ b/PowerForge/Services/ModulePipelineRunner.MissingAnalysis.cs @@ -513,8 +513,8 @@ private static bool IsBuiltInModule(string moduleName) private static bool IsBuiltInCommand(string name) => BuiltInCommandNames.Contains(name); - private sealed class MergeSourceInfo - { + private sealed class MergeSourceInfo + { public MergeSourceInfo(string psm1Path, string[] scriptFiles, string mergedScriptContent, bool hasLib) { Psm1Path = psm1Path; @@ -526,8 +526,62 @@ public MergeSourceInfo(string psm1Path, string[] scriptFiles, string mergedScrip public string Psm1Path { get; } public string[] ScriptFiles { get; } public string MergedScriptContent { get; } - public bool HasLib { get; } - public bool HasScripts => ScriptFiles.Length > 0; - } - -} + public bool HasLib { get; } + public bool HasScripts => ScriptFiles.Length > 0; + } + + private sealed class MergeExecutionResult + { + internal static readonly MergeExecutionResult None = new( + mergedModule: false, + usedExistingPsm1: false, + retainedBootstrapperBecauseBinaryOutputsDetected: false, + requiredModules: Array.Empty(), + approvedModules: Array.Empty(), + dependentModules: Array.Empty(), + topLevelInlinedFunctions: 0, + totalInlinedFunctions: 0, + scriptFilesDetected: 0, + hasBinaryOutputs: false, + hasScriptSources: false); + + internal bool MergedModule { get; } + internal bool UsedExistingPsm1 { get; } + internal bool RetainedBootstrapperBecauseBinaryOutputsDetected { get; } + internal RequiredModuleReference[] RequiredModules { get; } + internal string[] ApprovedModules { get; } + internal string[] DependentModules { get; } + internal int TopLevelInlinedFunctions { get; } + internal int TotalInlinedFunctions { get; } + internal int ScriptFilesDetected { get; } + internal bool HasBinaryOutputs { get; } + internal bool HasScriptSources { get; } + + internal MergeExecutionResult( + bool mergedModule, + bool usedExistingPsm1, + bool retainedBootstrapperBecauseBinaryOutputsDetected, + RequiredModuleReference[] requiredModules, + string[] approvedModules, + string[] dependentModules, + int topLevelInlinedFunctions, + int totalInlinedFunctions, + int scriptFilesDetected, + bool hasBinaryOutputs, + bool hasScriptSources) + { + MergedModule = mergedModule; + UsedExistingPsm1 = usedExistingPsm1; + RetainedBootstrapperBecauseBinaryOutputsDetected = retainedBootstrapperBecauseBinaryOutputsDetected; + RequiredModules = requiredModules ?? Array.Empty(); + ApprovedModules = approvedModules ?? Array.Empty(); + DependentModules = dependentModules ?? Array.Empty(); + TopLevelInlinedFunctions = topLevelInlinedFunctions; + TotalInlinedFunctions = totalInlinedFunctions; + ScriptFilesDetected = scriptFilesDetected; + HasBinaryOutputs = hasBinaryOutputs; + HasScriptSources = hasScriptSources; + } + } + +} diff --git a/PowerForge/Services/ModulePipelineRunner.Plan.cs b/PowerForge/Services/ModulePipelineRunner.Plan.cs index 2f3f2bdb..810780e1 100644 --- a/PowerForge/Services/ModulePipelineRunner.Plan.cs +++ b/PowerForge/Services/ModulePipelineRunner.Plan.cs @@ -509,11 +509,11 @@ public ModulePipelinePlan Plan(ModulePipelineSpec spec) exportAssemblies = new[] { inferred! }; } - var buildSpec = new ModuleBuildSpec - { - Name = moduleName, - SourcePath = projectRoot, - StagingPath = spec.Build.StagingPath, + var buildSpec = new ModuleBuildSpec + { + Name = moduleName, + SourcePath = projectRoot, + StagingPath = spec.Build.StagingPath, CsprojPath = refreshPsd1Only ? string.Empty : csproj, Version = resolved, Configuration = dotnetConfig, @@ -530,6 +530,12 @@ public ModulePipelinePlan Plan(ModulePipelineSpec spec) ExcludeLibraryFilter = excludeLibraryFilterFromSegments ?? spec.Build.ExcludeLibraryFilter ?? Array.Empty(), DoNotCopyLibrariesRecursively = doNotCopyLibrariesRecursivelyFromSegments ?? spec.Build.DoNotCopyLibrariesRecursively, DisableBinaryCmdletScan = disableBinaryCmdletScanFromSegments ?? spec.Build.DisableBinaryCmdletScan, + BinaryConflictPriorityModuleNames = requiredModulesDraft + .Select(static module => module.ModuleName) + .Where(static name => !string.IsNullOrWhiteSpace(name)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(), + BinaryConflictReportRoot = projectRoot, KeepStaging = spec.Build.KeepStaging, RefreshManifestOnly = refreshPsd1Only }; diff --git a/PowerForge/Services/ModulePipelineRunner.RefreshOnlySync.cs b/PowerForge/Services/ModulePipelineRunner.RefreshOnlySync.cs index 3d418558..6195410f 100644 --- a/PowerForge/Services/ModulePipelineRunner.RefreshOnlySync.cs +++ b/PowerForge/Services/ModulePipelineRunner.RefreshOnlySync.cs @@ -5,18 +5,28 @@ namespace PowerForge; public sealed partial class ModulePipelineRunner { - internal void SyncRefreshManifestToProjectRoot( + internal string? SyncBuildManifestToProjectRoot( ModulePipelinePlan plan) { - if (!plan.BuildSpec.RefreshManifestOnly) - return; - var projectManifestPath = GetProjectManifestPath(plan); if (!File.Exists(projectManifestPath)) - return; + return null; RefreshProjectManifestFromPlan(plan, projectManifestPath); - _logger.Info("RefreshPSD1Only: refreshed project-root manifest from source manifest inputs."); + + var label = plan.BuildSpec.RefreshManifestOnly ? "RefreshPSD1Only" : "Build"; + var message = $"{label}: refreshed project-root manifest from source manifest inputs."; + _logger.Info(message); + return message; + } + + internal void SyncRefreshManifestToProjectRoot( + ModulePipelinePlan plan) + { + if (!plan.BuildSpec.RefreshManifestOnly) + return; + + _ = SyncBuildManifestToProjectRoot(plan); } internal void SyncPublishedManifestToProjectRoot( @@ -34,7 +44,6 @@ internal void SyncPublishedManifestToProjectRoot( return; RefreshProjectManifestFromPlan(plan, projectManifestPath); - _logger.Info("Publish: refreshed project-root manifest from source manifest inputs."); } diff --git a/PowerForge/Services/ModulePipelineRunner.Run.Helpers.cs b/PowerForge/Services/ModulePipelineRunner.Run.Helpers.cs index ee418233..4684f770 100644 --- a/PowerForge/Services/ModulePipelineRunner.Run.Helpers.cs +++ b/PowerForge/Services/ModulePipelineRunner.Run.Helpers.cs @@ -6,13 +6,13 @@ namespace PowerForge; public sealed partial class ModulePipelineRunner { - private void EnsureBuildDependenciesInstalledIfNeeded(ModulePipelinePlan plan) + private ModuleDependencyInstallResult[] EnsureBuildDependenciesInstalledIfNeeded(ModulePipelinePlan plan) { - if (!plan.InstallMissingModules) return; + if (!plan.InstallMissingModules) return Array.Empty(); try { - _ = EnsureBuildDependenciesInstalled(plan); + return EnsureBuildDependenciesInstalled(plan); } catch (Exception ex) { @@ -62,7 +62,11 @@ private ModulePipelineResult BuildPipelineResult( CheckStatus? projectRootFileConsistencyStatus, ProjectConversionResult? projectRootFileConsistencyEncodingFix, ProjectConversionResult? projectRootFileConsistencyLineEndingFix, - ModuleSigningResult? signingResult) + ModuleSigningResult? signingResult, + ModuleDependencyInstallResult[] dependencyInstallResults, + ModuleBuildPipeline.StagingResult stagingResult, + MergeExecutionResult mergeExecution, + string? projectManifestSyncMessage) { var diagnostics = new List(BuildDiagnosticsFactory.CreatePipelineDiagnostics( fileConsistencyReport, @@ -103,7 +107,15 @@ private ModulePipelineResult BuildPipelineResult( projectRootFileConsistencyStatus, projectRootFileConsistencyEncodingFix, projectRootFileConsistencyLineEndingFix, - signingResult); + signingResult, + BuildOwnerNotes( + plan, + buildResult, + documentationResult, + dependencyInstallResults, + stagingResult, + mergeExecution, + projectManifestSyncMessage)); if (diagnosticsPolicy?.PolicyViolated == true) throw new ModulePipelineDiagnosticsPolicyException(result, diagnosticsPolicy, diagnosticsPolicy.FailureReason); @@ -111,6 +123,135 @@ private ModulePipelineResult BuildPipelineResult( return result; } + private ModuleOwnerNote[] BuildOwnerNotes( + ModulePipelinePlan plan, + ModuleBuildResult buildResult, + DocumentationBuildResult? documentationResult, + ModuleDependencyInstallResult[]? dependencyInstallResults, + ModuleBuildPipeline.StagingResult? stagingResult, + MergeExecutionResult? mergeExecution, + string? projectManifestSyncMessage) + { + var notes = new List(); + + if (dependencyInstallResults is { Length: > 0 }) + { + var installed = dependencyInstallResults.Count(static result => result.Status == ModuleDependencyInstallStatus.Installed); + var updated = dependencyInstallResults.Count(static result => result.Status == ModuleDependencyInstallStatus.Updated); + var satisfied = dependencyInstallResults.Count(static result => result.Status == ModuleDependencyInstallStatus.Satisfied); + var skipped = dependencyInstallResults.Count(static result => result.Status == ModuleDependencyInstallStatus.Skipped); + var listed = string.Join(", ", dependencyInstallResults.Select(static result => result.Name).Where(static name => !string.IsNullOrWhiteSpace(name)).Distinct(StringComparer.OrdinalIgnoreCase)); + + notes.Add(new ModuleOwnerNote( + "Dependencies", + ModuleOwnerNoteSeverity.Info, + summary: $"Checked {dependencyInstallResults.Length} dependency module(s): {listed}.", + nextStep: installed > 0 || updated > 0 + ? "If the build environment changed, re-run the module import step if you are troubleshooting dependency-related behavior." + : string.Empty, + details: new[] + { + $"{installed} installed, {updated} updated, {satisfied} satisfied, {skipped} skipped." + })); + } + + if (stagingResult is not null && (stagingResult.NormalizedLineEndingsCount > 0 || stagingResult.LineEndingNormalizationErrors > 0)) + { + var lines = new List(); + if (stagingResult.NormalizedLineEndingsCount > 0) + lines.Add($"Normalized staged line endings to CRLF for {stagingResult.NormalizedLineEndingsCount} file(s)."); + if (stagingResult.LineEndingNormalizationErrors > 0) + lines.Add($"Failed to normalize {stagingResult.LineEndingNormalizationErrors} staged file(s)."); + + notes.Add(new ModuleOwnerNote( + "Staging", + stagingResult.LineEndingNormalizationErrors > 0 ? ModuleOwnerNoteSeverity.Warning : ModuleOwnerNoteSeverity.Info, + summary: stagingResult.LineEndingNormalizationErrors > 0 + ? $"Normalized {stagingResult.NormalizedLineEndingsCount} staged file(s) and failed to normalize {stagingResult.LineEndingNormalizationErrors}." + : $"Normalized staged line endings to CRLF for {stagingResult.NormalizedLineEndingsCount} file(s).", + whyItMatters: stagingResult.LineEndingNormalizationErrors > 0 + ? "A normalization failure means staged content may not match the expected packaging format." + : string.Empty, + nextStep: stagingResult.LineEndingNormalizationErrors > 0 + ? "Review the affected staged files before publishing." + : string.Empty, + details: lines.ToArray())); + } + + if (buildResult.BuildNotes is { Length: > 0 }) + notes.AddRange(buildResult.BuildNotes); + + if (mergeExecution is not null && + (mergeExecution.RequiredModules.Length > 0 || + mergeExecution.ApprovedModules.Length > 0 || + mergeExecution.DependentModules.Length > 0 || + mergeExecution.TopLevelInlinedFunctions > 0 || + mergeExecution.TotalInlinedFunctions > 0 || + mergeExecution.UsedExistingPsm1 || + mergeExecution.RetainedBootstrapperBecauseBinaryOutputsDetected || + mergeExecution.MergedModule)) + { + notes.Add(new ModuleOwnerNote( + "Module Entry Script", + ModuleOwnerNoteSeverity.Info, + summary: mergeExecution.RetainedBootstrapperBecauseBinaryOutputsDetected + ? "Build kept the existing .psm1 entry script because the module contains binaries." + : mergeExecution.UsedExistingPsm1 && !mergeExecution.HasScriptSources + ? "Build reused the existing .psm1 entry script because there were no script sources to merge." + : mergeExecution.MergedModule + ? $"Build wrote a merged .psm1 entry script from {mergeExecution.ScriptFilesDetected} script source file(s)." + : "Build entry script did not require changes.", + details: Array.Empty())); + } + + if (documentationResult is not null && + documentationResult.Succeeded && + plan.DocumentationBuild?.UpdateWhenNew == true && + plan.Documentation is not null) + { + var docsPath = ResolvePath(plan.ProjectRoot, plan.Documentation.Path); + notes.Add(new ModuleOwnerNote( + "Documentation", + ModuleOwnerNoteSeverity.Info, + summary: $"Updated project documentation at '{docsPath}'.", + details: new[] + { + $"Generated {documentationResult.MarkdownFiles} markdown help file(s)." + })); + } + + if (!string.IsNullOrWhiteSpace(projectManifestSyncMessage)) + { + var manifestSyncMessage = projectManifestSyncMessage!; + notes.Add(new ModuleOwnerNote( + "Manifest", + ModuleOwnerNoteSeverity.Info, + summary: manifestSyncMessage.Trim(), + details: new[] + { + $"Project manifest now tracks the resolved build version {plan.ResolvedVersion}." + })); + } + + return notes.ToArray(); + } + + private static string SummarizeItems(IEnumerable? items, int maxItems) + { + var values = (items ?? Array.Empty()) + .Where(static item => !string.IsNullOrWhiteSpace(item)) + .Select(static item => item.Trim()) + .ToArray(); + + if (values.Length == 0) + return "none"; + + if (values.Length <= maxItems) + return string.Join(", ", values); + + return string.Join(", ", values.Take(maxItems)) + $", +{values.Length - maxItems} more"; + } + private BuildDiagnostic[] CreateBinaryConflictDiagnostics( ModulePipelineDiagnosticsOptions? options, ModulePipelinePlan plan, diff --git a/PowerForge/Services/ModulePipelineRunner.Run.cs b/PowerForge/Services/ModulePipelineRunner.Run.cs index c8e55218..e1801247 100644 --- a/PowerForge/Services/ModulePipelineRunner.Run.cs +++ b/PowerForge/Services/ModulePipelineRunner.Run.cs @@ -74,7 +74,7 @@ public ModulePipelineResult Run(ModulePipelineSpec spec, ModulePipelinePlan plan try { - EnsureBuildDependenciesInstalledIfNeeded(plan); + var dependencyInstallResults = EnsureBuildDependenciesInstalledIfNeeded(plan); ModuleBuildPipeline.StagingResult staged; SafeStart(reporter, startedKeys, stageStep); @@ -104,7 +104,8 @@ public ModulePipelineResult Run(ModulePipelineSpec spec, ModulePipelinePlan plan throw; } - var mergedScripts = plan.BuildSpec.RefreshManifestOnly ? false : ApplyMerge(plan, buildResult); + var mergeExecution = plan.BuildSpec.RefreshManifestOnly ? MergeExecutionResult.None : ApplyMerge(plan, buildResult); + var mergedScripts = mergeExecution.MergedModule; if (!plan.BuildSpec.RefreshManifestOnly) ApplyPlaceholders(plan, buildResult); @@ -281,8 +282,6 @@ public ModulePipelineResult Run(ModulePipelineSpec spec, ModulePipelinePlan plan if (_logger.IsVerbose) _logger.Verbose(ex.ToString()); } - SyncRefreshManifestToProjectRoot(plan); - if (plan.SignModule) { SafeStart(reporter, startedKeys, signStep); @@ -725,10 +724,6 @@ public ModulePipelineResult Run(ModulePipelineSpec spec, ModulePipelinePlan plan throw; } } - - SyncPublishedManifestToProjectRoot( - plan, - publishResults); } ModuleInstallerResult? installResult = null; @@ -779,6 +774,8 @@ public ModulePipelineResult Run(ModulePipelineSpec spec, ModulePipelinePlan plan } } + var projectManifestSyncMessage = SyncBuildManifestToProjectRoot(plan); + return BuildPipelineResult( spec, plan, @@ -800,7 +797,11 @@ public ModulePipelineResult Run(ModulePipelineSpec spec, ModulePipelinePlan plan projectFileConsistencyStatus, projectFileConsistencyEncodingFix, projectFileConsistencyLineEndingFix, - signingResult); + signingResult, + dependencyInstallResults, + staged, + mergeExecution, + projectManifestSyncMessage); } catch (Exception ex) { diff --git a/Shared/SpectrePipelineSummaryWriter.cs b/Shared/SpectrePipelineSummaryWriter.cs index 5fa7e8fa..22125a0b 100644 --- a/Shared/SpectrePipelineSummaryWriter.cs +++ b/Shared/SpectrePipelineSummaryWriter.cs @@ -235,6 +235,16 @@ static int CountIssues(ProjectConsistencyReport report, FileConsistencySettings? table.AddRow($"{(unicode ? "💡" : "*")} Diagnostics", $"[grey]{Esc(detail)}[/]"); } + if (res.OwnerNotes is { Length: > 0 }) + { + var actionableCount = res.OwnerNotes.Count(static note => note is not null && note.Severity == ModuleOwnerNoteSeverity.Warning); + var fyiCount = res.OwnerNotes.Count(static note => note is not null && note.Severity != ModuleOwnerNoteSeverity.Warning); + var parts = new List(2); + if (actionableCount > 0) parts.Add($"[yellow]{actionableCount} action needed[/]"); + if (fyiCount > 0) parts.Add($"[blue]{fyiCount} FYI[/]"); + table.AddRow($"{(unicode ? "📝" : "*")} Owner notes", parts.Count == 0 ? "[grey]None[/]" : string.Join(", ", parts)); + } + if (res.InstallResult is not null) table.AddRow($"{(unicode ? "📥" : "*")} Install", $"[green]{Esc(res.InstallResult.Version)}[/]"); else @@ -277,6 +287,7 @@ static int CountIssues(ProjectConsistencyReport report, FileConsistencySettings? var buildRecommendations = BuildRecommendations(res, BuildDiagnosticArea.Build); WriteRecommendationTable("Build advisories", buildRecommendations, border); + WriteOwnerNotes(res.OwnerNotes, border); if (res.FileConsistencyReport is not null && res.Plan.FileConsistencySettings?.Severity != ValidationSeverity.Off) WriteFileConsistencyIssues( @@ -720,6 +731,141 @@ private static void WriteRecommendationTable( AnsiConsole.Write(table); } + private static void WriteOwnerNotes( + IReadOnlyList? notes, + TableBorder border) + { + if (notes is null || notes.Count == 0) + return; + + var visibleNotes = notes + .Where(static note => note is not null) + .Select(static note => note!) + .Where(static note => + !string.IsNullOrWhiteSpace(note.Summary) || + !string.IsNullOrWhiteSpace(note.WhyItMatters) || + !string.IsNullOrWhiteSpace(note.NextStep) || + (note.Details?.Length ?? 0) > 0) + .ToArray(); + + if (visibleNotes.Length == 0) + return; + + var actionable = visibleNotes + .Where(static note => note.Severity == ModuleOwnerNoteSeverity.Warning) + .ToArray(); + var fyi = visibleNotes + .Where(static note => note.Severity != ModuleOwnerNoteSeverity.Warning) + .ToArray(); + + AnsiConsole.WriteLine(); + AnsiConsole.Write(new Rule("[grey]Owner Notes[/]").LeftJustified()); + + WriteOwnerNoteGroup("Action Needed", actionable, border, "[yellow]"); + WriteOwnerNoteSummaryTable("FYI", fyi, border, "[blue]"); + } + + private static void WriteOwnerNoteGroup( + string title, + IEnumerable entries, + TableBorder border, + string colorMarkup) + { + static string Esc(string? s) => Markup.Escape(s ?? string.Empty); + + var notes = entries.ToArray(); + if (notes.Length == 0) + return; + + AnsiConsole.Write(new Rule($"{colorMarkup}{Esc(title)}[/]").LeftJustified()); + + foreach (var note in notes) + { + var body = new List(); + + if (!string.IsNullOrWhiteSpace(note.Summary)) + body.Add(Esc(note.Summary.Trim())); + + if (!string.IsNullOrWhiteSpace(note.WhyItMatters)) + body.Add($"[grey]When it matters:[/] {Esc(note.WhyItMatters.Trim())}"); + + if (!string.IsNullOrWhiteSpace(note.NextStep)) + body.Add($"[grey]What to do:[/] {Esc(note.NextStep.Trim())}"); + + var details = (note.Details ?? Array.Empty()) + .Where(static line => !string.IsNullOrWhiteSpace(line)) + .Select(static line => line.Trim()) + .ToArray(); + if (details.Length > 0) + body.AddRange(details.Select(detail => $"[grey]-[/] {Esc(detail)}")); + + if (body.Count == 0) + continue; + + var panel = new Panel(new Markup(string.Join(Environment.NewLine, body))) + { + Border = BoxBorder.Rounded, + Header = new PanelHeader($"{colorMarkup}{Esc(note.Title)}[/]"), + Expand = true + }; + + AnsiConsole.Write(panel); + } + } + + private static void WriteOwnerNoteSummaryTable( + string title, + IEnumerable entries, + TableBorder border, + string colorMarkup) + { + static string Esc(string? s) => Markup.Escape(s ?? string.Empty); + + var notes = entries.ToArray(); + if (notes.Length == 0) + return; + + AnsiConsole.Write(new Rule($"{colorMarkup}{Esc(title)}[/]").LeftJustified()); + + var table = new Table() + .Border(border) + .AddColumn(new TableColumn("Topic").NoWrap()) + .AddColumn(new TableColumn("Summary")); + + foreach (var note in notes) + { + var summary = BuildCompactOwnerNoteSummary(note); + if (string.IsNullOrWhiteSpace(summary)) + continue; + + table.AddRow(Esc(note.Title), Esc(summary)); + } + + if (table.Rows.Count > 0) + AnsiConsole.Write(table); + } + + private static string BuildCompactOwnerNoteSummary(ModuleOwnerNote note) + { + if (note is null) + return string.Empty; + + var parts = new List(); + if (!string.IsNullOrWhiteSpace(note.Summary)) + parts.Add(note.Summary.Trim()); + + var details = (note.Details ?? Array.Empty()) + .Where(static line => !string.IsNullOrWhiteSpace(line)) + .Select(static line => line.Trim()) + .Where(line => parts.Count == 0 || !line.Equals(parts[0], StringComparison.OrdinalIgnoreCase)) + .Take(1) + .ToArray(); + if (details.Length > 0) + parts.Add(details[0]); + + return string.Join(" ", parts.Where(static part => !string.IsNullOrWhiteSpace(part))); + } + private static string BuildRecommendationAction(BuildDiagnostic diagnostic) { if (diagnostic is null) From 766c7cdd54307a4a0b83bce917eedbd6aa46269f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sat, 14 Mar 2026 09:08:35 +0100 Subject: [PATCH 2/2] Validate module version inputs and trim review leftovers --- Module/Docs/Readme.md | 2 +- Module/PSPublishModule.psd1 | 29 +-- .../Services/ModuleBuildPreparationService.cs | 36 ++- .../ModuleBuildPreparationServiceTests.cs | 217 ++++++++++++++++++ ...oduleBuilderBinaryConflictAdvisoryTests.cs | 41 ++++ PowerForge.Tests/ModuleVersionStepperTests.cs | 91 ++++++++ PowerForge/Services/ModuleBuildPipeline.cs | 2 + PowerForge/Services/ModuleBuilder.cs | 45 +--- .../ModulePipelineRunner.Run.Helpers.cs | 16 -- PowerForge/Services/ModuleVersionStepper.cs | 34 +++ 10 files changed, 437 insertions(+), 76 deletions(-) diff --git a/Module/Docs/Readme.md b/Module/Docs/Readme.md index 300cf0fe..5b601a5b 100644 --- a/Module/Docs/Readme.md +++ b/Module/Docs/Readme.md @@ -2,7 +2,7 @@ Module Name: PSPublishModule Module Guid: eb76426a-1992-40a5-82cd-6480f883ef4d Download Help Link: https://github.com/EvotecIT/PSPublishModule -Help Version: 3.0.0 +Help Version: 3.0.1 Locale: en-US --- # PSPublishModule Module diff --git a/Module/PSPublishModule.psd1 b/Module/PSPublishModule.psd1 index 7b89e78f..93ca4198 100644 --- a/Module/PSPublishModule.psd1 +++ b/Module/PSPublishModule.psd1 @@ -9,28 +9,29 @@ DotNetFrameworkVersion = '4.5.2' FunctionsToExport = @() GUID = 'eb76426a-1992-40a5-82cd-6480f883ef4d' - ModuleVersion = '3.0.0' + ModuleVersion = '3.0.1' PowerShellVersion = '5.1' PrivateData = @{ PSData = @{ - IconUri = 'https://evotec.xyz/wp-content/uploads/2019/02/PSPublishModule.png' - ProjectUri = 'https://github.com/EvotecIT/PSPublishModule' - RequireLicenseAcceptance = $false - Tags = @('Windows', 'MacOS', 'Linux', 'Build', 'Module') + IconUri = 'https://evotec.xyz/wp-content/uploads/2019/02/PSPublishModule.png' + ProjectUri = 'https://github.com/EvotecIT/PSPublishModule' + RequireLicenseAcceptance = $false + Tags = @('Windows', 'MacOS', 'Linux', 'Build', 'Module') + ExternalModuleDependencies = @() } } RequiredModules = @(@{ - Guid = '1d73a601-4a6c-43c5-ba3f-619b18bbb404' - ModuleName = 'powershellget' - ModuleVersion = '2.2.5' + Guid = '1d73a601-4a6c-43c5-ba3f-619b18bbb404' + ModuleName = 'powershellget' + ModuleVersion = '2.2.5' }, @{ - Guid = 'd6245802-193d-4068-a631-8863a4342a18' - ModuleName = 'PSScriptAnalyzer' - ModuleVersion = '1.24.0' + Guid = 'd6245802-193d-4068-a631-8863a4342a18' + ModuleName = 'PSScriptAnalyzer' + ModuleVersion = '1.24.0' }, @{ - Guid = 'a699dea5-2c73-4616-a270-1f7abb777e71' - ModuleName = 'Pester' - ModuleVersion = '5.7.1' + Guid = 'a699dea5-2c73-4616-a270-1f7abb777e71' + ModuleName = 'Pester' + ModuleVersion = '5.7.1' }) RootModule = 'PSPublishModule.psm1' NestedModules = @() diff --git a/PowerForge.PowerShell/Services/ModuleBuildPreparationService.cs b/PowerForge.PowerShell/Services/ModuleBuildPreparationService.cs index edd91e29..164da372 100644 --- a/PowerForge.PowerShell/Services/ModuleBuildPreparationService.cs +++ b/PowerForge.PowerShell/Services/ModuleBuildPreparationService.cs @@ -34,7 +34,6 @@ public ModuleBuildPreparedContext Prepare(ModuleBuildPreparationRequest request) : LegacySegmentAdapter.CollectFromSettings(request.Settings)) : Array.Empty(); - var baseVersion = ResolveBaseVersion(projectRoot, moduleName!); var frameworks = useLegacy && !request.DotNetFrameworkWasBound ? Array.Empty() : request.DotNetFramework; @@ -47,7 +46,7 @@ public ModuleBuildPreparedContext Prepare(ModuleBuildPreparationRequest request) SourcePath = projectRoot, StagingPath = request.StagingPath, CsprojPath = request.CsprojPath, - Version = baseVersion, + Version = "1.0.0", Configuration = request.DotNetConfiguration, Frameworks = frameworks, KeepStaging = request.KeepStaging, @@ -76,6 +75,8 @@ public ModuleBuildPreparedContext Prepare(ModuleBuildPreparationRequest request) Segments = segments }; + spec.Build.Version = ResolveBaseVersion(projectRoot, moduleName!, segments); + return new ModuleBuildPreparedContext { ModuleName = moduleName!, @@ -110,6 +111,37 @@ public void WritePipelineSpecJson(ModulePipelineSpec spec, string jsonFullPath) File.WriteAllText(jsonFullPath, json, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); } + private static string ResolveBaseVersion(string projectRoot, string moduleName, IReadOnlyList? segments) + { + var configuredVersion = ResolveConfiguredVersion(segments); + if (!string.IsNullOrWhiteSpace(configuredVersion)) + return configuredVersion!; + + return ResolveBaseVersion(projectRoot, moduleName); + } + + private static string? ResolveConfiguredVersion(IReadOnlyList? segments) + { + if (segments is null || segments.Count == 0) + return null; + + for (var index = segments.Count - 1; index >= 0; index--) + { + if (segments[index] is not ConfigurationManifestSegment manifest) + continue; + + var moduleVersion = manifest.Configuration?.ModuleVersion; + if (!string.IsNullOrWhiteSpace(moduleVersion)) + { + var trimmed = (moduleVersion ?? string.Empty).Trim(); + if (!string.IsNullOrWhiteSpace(trimmed)) + return trimmed; + } + } + + return null; + } + private static string ResolveBaseVersion(string projectRoot, string moduleName) { var baseVersion = "1.0.0"; diff --git a/PowerForge.Tests/ModuleBuildPreparationServiceTests.cs b/PowerForge.Tests/ModuleBuildPreparationServiceTests.cs index 19a02314..e9fb7565 100644 --- a/PowerForge.Tests/ModuleBuildPreparationServiceTests.cs +++ b/PowerForge.Tests/ModuleBuildPreparationServiceTests.cs @@ -1,4 +1,6 @@ using System.Collections; +using System.Text.Json; +using System.Text.Json.Serialization; using PowerForge; namespace PowerForge.Tests; @@ -89,6 +91,49 @@ public void Prepare_from_configuration_uses_legacy_module_name_and_manifest_vers } } + [Fact] + public void Prepare_prefers_configured_manifest_version_over_source_manifest_version() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "pf-modulebuild-configured-version-" + Guid.NewGuid().ToString("N"))); + + try + { + File.WriteAllText( + Path.Combine(root.FullName, "SampleModule.psd1"), + "@{ ModuleVersion = '3.0.0' }"); + + var configuration = new Hashtable + { + ["Information"] = new Hashtable + { + ["ModuleName"] = "SampleModule", + ["Manifest"] = new Hashtable + { + ["ModuleVersion"] = "3.0.X", + ["CompatiblePSEditions"] = new[] { "Desktop", "Core" }, + ["Author"] = "Przemyslaw Klys" + } + } + }; + + var prepared = new ModuleBuildPreparationService().Prepare(new ModuleBuildPreparationRequest + { + ParameterSetName = "Configuration", + Configuration = configuration, + CurrentPath = root.FullName, + ResolvePath = path => path + }); + + Assert.Equal("3.0.X", prepared.PipelineSpec.Build.Version); + var manifestSegment = Assert.IsType(Assert.Single(prepared.PipelineSpec.Segments)); + Assert.Equal("3.0.X", manifestSegment.Configuration.ModuleVersion); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + [Fact] public void WritePipelineSpecJson_rewrites_paths_relative_to_output() { @@ -125,4 +170,176 @@ public void WritePipelineSpecJson_rewrites_paths_relative_to_output() try { root.Delete(recursive: true); } catch { } } } + + [Fact] + public void WritePipelineSpecJson_preserves_configured_manifest_version_in_build_spec() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "pf-modulebuild-json-version-" + Guid.NewGuid().ToString("N"))); + + try + { + var jsonPath = Path.Combine(root.FullName, ".powerforge", "powerforge.json"); + var spec = new ModulePipelineSpec + { + Build = new ModuleBuildSpec + { + Name = "SampleModule", + SourcePath = root.FullName, + Version = "3.0.X" + }, + Segments = new IConfigurationSegment[] + { + new ConfigurationManifestSegment + { + Configuration = new ManifestConfiguration + { + ModuleVersion = "3.0.X", + Author = "Przemyslaw Klys" + } + } + } + }; + + new ModuleBuildPreparationService().WritePipelineSpecJson(spec, jsonPath); + + var json = File.ReadAllText(jsonPath); + Assert.Contains("\"Version\": \"3.0.X\"", json, StringComparison.Ordinal); + Assert.Contains("\"ModuleVersion\": \"3.0.X\"", json, StringComparison.Ordinal); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + + [Fact] + public void WritePipelineSpecJson_round_trips_pipeline_plan_without_losing_publish_or_version_data() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "pf-modulebuild-json-parity-" + Guid.NewGuid().ToString("N"))); + + try + { + const string moduleName = "SampleModule"; + File.WriteAllText(Path.Combine(root.FullName, $"{moduleName}.psd1"), "@{ ModuleVersion = '3.0.0' }"); + File.WriteAllText(Path.Combine(root.FullName, $"{moduleName}.psm1"), string.Empty); + + var spec = new ModulePipelineSpec + { + Build = new ModuleBuildSpec + { + Name = moduleName, + SourcePath = root.FullName, + Version = "3.0.X", + Configuration = "Release", + Frameworks = new[] { "net8.0", "net472" } + }, + Install = new ModulePipelineInstallOptions + { + Enabled = true, + Strategy = InstallationStrategy.AutoRevision, + KeepVersions = 3 + }, + Segments = new IConfigurationSegment[] + { + new ConfigurationManifestSegment + { + Configuration = new ManifestConfiguration + { + ModuleVersion = "3.0.X", + CompatiblePSEditions = new[] { "Desktop", "Core" }, + Guid = "eb76426a-1992-40a5-82cd-6480f883ef4d", + Author = "Przemyslaw Klys" + } + }, + new ConfigurationArtefactSegment + { + ArtefactType = ArtefactType.Unpacked, + Configuration = new ArtefactConfiguration + { + Enabled = true, + Path = "Artefacts/Unpacked/" + } + }, + new ConfigurationPublishSegment + { + Configuration = new PublishConfiguration + { + Destination = PublishDestination.PowerShellGallery, + Enabled = true, + RepositoryName = "PSGallery" + } + }, + new ConfigurationPublishSegment + { + Configuration = new PublishConfiguration + { + Destination = PublishDestination.GitHub, + Enabled = true, + ID = "ToGitHub", + UserName = "EvotecIT", + OverwriteTagName = "" + } + } + } + }; + + var runner = new ModulePipelineRunner(new NullLogger()); + var directPlan = runner.Plan(spec); + + var jsonPath = Path.Combine(root.FullName, ".powerforge", "powerforge.json"); + new ModuleBuildPreparationService().WritePipelineSpecJson(spec, jsonPath); + + var json = File.ReadAllText(jsonPath); + var jsonSpec = JsonSerializer.Deserialize(json, CreateJsonOptions()); + Assert.NotNull(jsonSpec); + + ResolvePipelineSpecPathsLikeCli(jsonSpec!, jsonPath); + var roundTrippedPlan = runner.Plan(jsonSpec!); + + Assert.Equal(directPlan.ExpectedVersion, roundTrippedPlan.ExpectedVersion); + Assert.Equal(directPlan.ResolvedVersion, roundTrippedPlan.ResolvedVersion); + Assert.Equal(directPlan.BuildSpec.Version, roundTrippedPlan.BuildSpec.Version); + Assert.Equal(directPlan.Publishes.Length, roundTrippedPlan.Publishes.Length); + Assert.Equal(directPlan.Artefacts.Length, roundTrippedPlan.Artefacts.Length); + Assert.Equal(directPlan.InstallEnabled, roundTrippedPlan.InstallEnabled); + Assert.Equal(directPlan.InstallStrategy, roundTrippedPlan.InstallStrategy); + Assert.Equal(directPlan.InstallKeepVersions, roundTrippedPlan.InstallKeepVersions); + Assert.Equal( + directPlan.Publishes.Select(p => p.Configuration.Destination).ToArray(), + roundTrippedPlan.Publishes.Select(p => p.Configuration.Destination).ToArray()); + Assert.Equal( + directPlan.Publishes.Select(p => p.Configuration.ID ?? string.Empty).ToArray(), + roundTrippedPlan.Publishes.Select(p => p.Configuration.ID ?? string.Empty).ToArray()); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + + private static JsonSerializerOptions CreateJsonOptions() + { + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + options.Converters.Add(new JsonStringEnumConverter()); + options.Converters.Add(new ConfigurationSegmentJsonConverter()); + return options; + } + + private static void ResolvePipelineSpecPathsLikeCli(ModulePipelineSpec spec, string configFullPath) + { + var baseDir = Path.GetDirectoryName(configFullPath) ?? Directory.GetCurrentDirectory(); + + if (!string.IsNullOrWhiteSpace(spec.Build.SourcePath)) + spec.Build.SourcePath = Path.GetFullPath(Path.IsPathRooted(spec.Build.SourcePath) ? spec.Build.SourcePath : Path.Combine(baseDir, spec.Build.SourcePath)); + + if (!string.IsNullOrWhiteSpace(spec.Build.StagingPath)) + spec.Build.StagingPath = Path.GetFullPath(Path.IsPathRooted(spec.Build.StagingPath) ? spec.Build.StagingPath! : Path.Combine(baseDir, spec.Build.StagingPath!)); + + if (!string.IsNullOrWhiteSpace(spec.Build.CsprojPath)) + spec.Build.CsprojPath = Path.GetFullPath(Path.IsPathRooted(spec.Build.CsprojPath) ? spec.Build.CsprojPath! : Path.Combine(baseDir, spec.Build.CsprojPath!)); + } } diff --git a/PowerForge.Tests/ModuleBuilderBinaryConflictAdvisoryTests.cs b/PowerForge.Tests/ModuleBuilderBinaryConflictAdvisoryTests.cs index e9e029a9..8ae39629 100644 --- a/PowerForge.Tests/ModuleBuilderBinaryConflictAdvisoryTests.cs +++ b/PowerForge.Tests/ModuleBuilderBinaryConflictAdvisoryTests.cs @@ -65,6 +65,47 @@ public void BuildBinaryConflictAdvisorySummary_ExplainsWhenTheWarningIsActionabl Assert.Contains("LegacyModule 1.0.0", advisory.Actionability, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void WriteBinaryConflictReport_WritesDedupedModulesAndExactVersionPairs() + { + var result = new BinaryConflictDetectionResult( + powerShellEdition: "Desktop", + moduleRoot: @"C:\Repo\TestModule", + assemblyRootPath: @"C:\Repo\TestModule\Lib\Default", + assemblyRootRelativePath: @"Lib\Default", + issues: new[] + { + CreateIssue("System.Memory", "4.0.5.0", "LegacyModule", "1.0.0", "4.0.1.2", 1), + CreateIssue("System.Text.Json", "9.0.0.0", "LegacyModule", "2.0.0", "8.0.0.6", 1), + CreateIssue("System.Memory", "4.0.5.0", "OtherModule", "3.0.0", "4.0.6.0", -1) + }, + summary: "3 conflicts across 1 module source"); + + var advisory = ModuleBuilder.BuildBinaryConflictAdvisorySummary(result); + var reportsRoot = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "pf-binary-conflict-report-" + Guid.NewGuid().ToString("N"))); + + try + { + var builder = new ModuleBuilder(new NullLogger()); + var reportPath = builder.WriteBinaryConflictReport(reportsRoot.FullName, advisory, result); + + Assert.False(string.IsNullOrWhiteSpace(reportPath)); + Assert.True(File.Exists(reportPath), "Expected binary conflict report file to exist."); + + var text = File.ReadAllText(reportPath!); + Assert.Contains("Binary conflict report for Desktop", text, StringComparison.Ordinal); + Assert.Contains("Installed modules below already keep only the newest installed version per module name.", text, StringComparison.Ordinal); + Assert.DoesNotContain("LegacyModule 1.0.0", text, StringComparison.Ordinal); + Assert.Contains("LegacyModule 2.0.0", text, StringComparison.Ordinal); + Assert.Contains("System.Text.Json: ours 9.0.0.0, theirs 8.0.0.6 (ours newer)", text, StringComparison.Ordinal); + Assert.Contains("OtherModule 3.0.0", text, StringComparison.Ordinal); + } + finally + { + try { reportsRoot.Delete(recursive: true); } catch { } + } + } + private static BinaryConflictDetectionIssue CreateIssue( string assemblyName, string payloadAssemblyVersion, diff --git a/PowerForge.Tests/ModuleVersionStepperTests.cs b/PowerForge.Tests/ModuleVersionStepperTests.cs index 22b91eb6..f7cd7418 100644 --- a/PowerForge.Tests/ModuleVersionStepperTests.cs +++ b/PowerForge.Tests/ModuleVersionStepperTests.cs @@ -38,6 +38,38 @@ public void Step_UsesReservedPowerShellGalleryVersionWhenFeedOmitsIt() Assert.Equal("3.0.0", result.CurrentVersion); } + [Fact] + public void Step_ConfirmsCandidateVersionIsFree_WhenRepositoryLookupReturnsNothing() + { + using var client = new HttpClient(new FakeCandidateReservationHandler("3.0.0")); + var stepper = new ModuleVersionStepper( + new NullLogger(), + new StubPowerShellRunner(new PowerShellRunResult(0, string.Empty, string.Empty, "pwsh.exe")), + client); + + var result = stepper.Step("3.0.X", moduleName: "PSPublishModule", localPsd1Path: null, repository: "PSGallery"); + + Assert.Equal("3.0.1", result.Version); + Assert.Equal(ModuleVersionSource.Repository, result.CurrentVersionSource); + Assert.Equal("3.0.0", result.CurrentVersion); + } + + [Fact] + public void Step_ContinuesBumpingUntilExactCandidateIsFree() + { + using var client = new HttpClient(new FakeCandidateReservationHandler("3.0.0", "3.0.1")); + var stepper = new ModuleVersionStepper( + new NullLogger(), + new StubPowerShellRunner(new PowerShellRunResult(0, string.Empty, string.Empty, "pwsh.exe")), + client); + + var result = stepper.Step("3.0.X", moduleName: "PSPublishModule", localPsd1Path: null, repository: "PSGallery"); + + Assert.Equal("3.0.2", result.Version); + Assert.Equal(ModuleVersionSource.Repository, result.CurrentVersionSource); + Assert.Equal("3.0.1", result.CurrentVersion); + } + private static string VisibleRepositoryItem(string name, string version) => string.Join("::", new[] { @@ -179,4 +211,63 @@ protected override Task SendAsync(HttpRequestMessage reques return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); } } + + private sealed class FakeCandidateReservationHandler : HttpMessageHandler + { + private readonly HashSet _reservedVersions; + + public FakeCandidateReservationHandler(params string[] reservedVersions) + { + _reservedVersions = new HashSet(reservedVersions ?? Array.Empty(), StringComparer.OrdinalIgnoreCase); + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var uri = request.RequestUri?.ToString() ?? string.Empty; + + if (uri.Contains("FindPackagesById", StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(""" + + + + """, Encoding.UTF8, "application/atom+xml") + }); + } + + if (uri.Contains("Packages(", StringComparison.OrdinalIgnoreCase) && + uri.Contains("PSPublishModule", StringComparison.OrdinalIgnoreCase)) + { + foreach (var version in _reservedVersions) + { + if (uri.Contains(version, StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent($$""" + + + + + {{version}} + false + 1900-01-01T00:00:00 + + + """, Encoding.UTF8, "application/atom+xml") + }); + } + } + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); + } + } } diff --git a/PowerForge/Services/ModuleBuildPipeline.cs b/PowerForge/Services/ModuleBuildPipeline.cs index d0f5cb49..c04f953c 100644 --- a/PowerForge/Services/ModuleBuildPipeline.cs +++ b/PowerForge/Services/ModuleBuildPipeline.cs @@ -322,6 +322,8 @@ private static ExportSet ReadExportsFromManifest(string psd1Path) catch (Exception ex) { _logger.Warn($"Mixed line-ending normalization in staging failed: {ex.Message}"); + // This outer catch can fire before the converter returns per-file totals, so the error count is an + // approximate "normalization failed" signal rather than an exact count of failed files. return (0, 1); } } diff --git a/PowerForge/Services/ModuleBuilder.cs b/PowerForge/Services/ModuleBuilder.cs index a87335a2..c6d9704e 100644 --- a/PowerForge/Services/ModuleBuilder.cs +++ b/PowerForge/Services/ModuleBuilder.cs @@ -505,7 +505,7 @@ private ModuleOwnerNote[] WarnOnInstalledBinaryConflicts(Options opts) editionStatuses.Add((result.PowerShellEdition, true)); var reportPath = WriteBinaryConflictReport(opts.BinaryConflictReportRoot, advisory, result); _logger.Warn($"Binary conflict advisory ({result.PowerShellEdition}): {result.Summary}."); - _logger.Warn($" Scope: {BuildBinaryConflictScopeText(advisory)}"); + _logger.Warn($" Scope: {BuildDeclaredDependencyModulesText(advisory)}"); foreach (var module in advisory.AllModules) { @@ -611,33 +611,12 @@ internal static BinaryConflictAdvisorySummary BuildBinaryConflictAdvisorySummary : moduleName + " " + moduleVersion; return new BinaryConflictModuleSummary( moduleLabel: label, - isPriority: priorityNames.Contains(moduleName, StringComparer.OrdinalIgnoreCase), conflictCount: group.Count(), distinctAssemblies: group .Select(static issue => issue.AssemblyName) .Where(static name => !string.IsNullOrWhiteSpace(name)) .Distinct(StringComparer.OrdinalIgnoreCase) .Count(), - topAssemblies: group - .Select(static issue => issue.AssemblyName) - .Where(static name => !string.IsNullOrWhiteSpace(name)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(static name => name, StringComparer.OrdinalIgnoreCase) - .Take(3) - .ToArray(), - examples: group - .GroupBy(static issue => issue.AssemblyName, StringComparer.OrdinalIgnoreCase) - .Select(static assemblyGroup => assemblyGroup - .OrderByDescending(static issue => Math.Abs(issue.VersionComparison)) - .ThenBy(static issue => issue.AssemblyName, StringComparer.OrdinalIgnoreCase) - .First()) - .OrderBy(static issue => issue.AssemblyName, StringComparer.OrdinalIgnoreCase) - .Take(2) - .Select(static issue => new BinaryConflictExampleSummary( - issue.AssemblyName, - issue.PayloadAssemblyVersion, - issue.InstalledAssemblyVersion)) - .ToArray(), mismatches: group .GroupBy(static issue => issue.AssemblyName, StringComparer.OrdinalIgnoreCase) .Select(static assemblyGroup => assemblyGroup @@ -774,7 +753,7 @@ private static string[] BuildBinaryConflictOwnerDetails(BinaryConflictAdvisorySu return details.ToArray(); } - private string? WriteBinaryConflictReport( + internal string? WriteBinaryConflictReport( string? reportRoot, BinaryConflictAdvisorySummary advisory, BinaryConflictDetectionResult result) @@ -822,11 +801,6 @@ private static string[] BuildBinaryConflictOwnerDetails(BinaryConflictAdvisorySu } } - private static string BuildBinaryConflictScopeText(BinaryConflictAdvisorySummary advisory) - { - return BuildDeclaredDependencyModulesText(advisory); - } - private static string BuildBinaryConflictSessionLabel(string? powerShellEdition) { if (string.Equals(powerShellEdition, "Desktop", StringComparison.OrdinalIgnoreCase)) @@ -881,12 +855,6 @@ private static string BuildBinaryConflictModuleSummaryLine(BinaryConflictModuleS return $"{prefix}{module.DistinctAssemblies} shared assemblies differ; {BuildBinaryConflictVersionDirectionText(module)}."; } - private static string BuildBinaryConflictModuleOwnerDetail(BinaryConflictModuleSummary module, bool includeModuleLabel) - { - var prefix = includeModuleLabel ? module.ModuleLabel + ": " : string.Empty; - return $"{prefix}{module.DistinctAssemblies} shared assemblies differ; {BuildBinaryConflictVersionDirectionText(module)}; {BuildBinaryConflictModuleCheckText(module)}"; - } - private PublishCopyPlan CreateCopyPlan(string publishDir, string tfm, PublishCopyOptions options) { var plan = TryCreateCopyPlanFromDeps(publishDir, tfm, options); @@ -1310,32 +1278,23 @@ internal BinaryConflictAdvisorySummary( internal sealed class BinaryConflictModuleSummary { internal string ModuleLabel { get; } - internal bool IsPriority { get; } internal int ConflictCount { get; } internal int DistinctAssemblies { get; } - internal string[] TopAssemblies { get; } - internal BinaryConflictExampleSummary[] Examples { get; } internal BinaryConflictExampleSummary[] Mismatches { get; } internal int PayloadNewerCount { get; } internal int PayloadOlderCount { get; } internal BinaryConflictModuleSummary( string moduleLabel, - bool isPriority, int conflictCount, int distinctAssemblies, - string[] topAssemblies, - BinaryConflictExampleSummary[] examples, BinaryConflictExampleSummary[] mismatches, int payloadNewerCount, int payloadOlderCount) { ModuleLabel = moduleLabel ?? string.Empty; - IsPriority = isPriority; ConflictCount = conflictCount; DistinctAssemblies = distinctAssemblies; - TopAssemblies = topAssemblies ?? Array.Empty(); - Examples = examples ?? Array.Empty(); Mismatches = mismatches ?? Array.Empty(); PayloadNewerCount = payloadNewerCount; PayloadOlderCount = payloadOlderCount; diff --git a/PowerForge/Services/ModulePipelineRunner.Run.Helpers.cs b/PowerForge/Services/ModulePipelineRunner.Run.Helpers.cs index 4684f770..496a23ad 100644 --- a/PowerForge/Services/ModulePipelineRunner.Run.Helpers.cs +++ b/PowerForge/Services/ModulePipelineRunner.Run.Helpers.cs @@ -236,22 +236,6 @@ private ModuleOwnerNote[] BuildOwnerNotes( return notes.ToArray(); } - private static string SummarizeItems(IEnumerable? items, int maxItems) - { - var values = (items ?? Array.Empty()) - .Where(static item => !string.IsNullOrWhiteSpace(item)) - .Select(static item => item.Trim()) - .ToArray(); - - if (values.Length == 0) - return "none"; - - if (values.Length <= maxItems) - return string.Join(", ", values); - - return string.Join(", ", values.Take(maxItems)) + $", +{values.Length - maxItems} more"; - } - private BuildDiagnostic[] CreateBinaryConflictDiagnostics( ModulePipelineDiagnosticsOptions? options, ModulePipelinePlan plan, diff --git a/PowerForge/Services/ModuleVersionStepper.cs b/PowerForge/Services/ModuleVersionStepper.cs index 77327841..56ae2ef9 100644 --- a/PowerForge/Services/ModuleVersionStepper.cs +++ b/PowerForge/Services/ModuleVersionStepper.cs @@ -70,6 +70,7 @@ public ModuleVersionStepResult Step( var (current, source) = ResolveCurrentVersion(expectedVersion, moduleName, localPsd1Path, repository, prerelease); var proposed = ComputeNextVersion(expectedVersion, current); + proposed = EnsureResolvedVersionIsAvailable(expectedVersion, moduleName, repository, prerelease, proposed); return new ModuleVersionStepResult( expectedVersion: expectedVersion, @@ -206,6 +207,39 @@ public ModuleVersionStepResult Step( return latestReserved; } + private string EnsureResolvedVersionIsAvailable( + string expectedVersion, + string? moduleName, + string repository, + bool prerelease, + string proposedVersion) + { + if (prerelease || + string.IsNullOrWhiteSpace(moduleName) || + string.IsNullOrWhiteSpace(proposedVersion) || + !string.Equals(repository, "PSGallery", StringComparison.OrdinalIgnoreCase)) + { + return proposedVersion; + } + + var candidateText = proposedVersion; + const int maxProbeCount = 24; + + for (var index = 0; index < maxProbeCount; index++) + { + if (!_powerShellGalleryFeed.VersionExists(moduleName!, candidateText, timeout: TimeSpan.FromSeconds(20))) + return candidateText; + + if (!TryParseRepositoryVersion(candidateText, out var candidateVersion)) + return candidateText; + + candidateText = ComputeNextVersion(expectedVersion, candidateVersion); + } + + throw new InvalidOperationException( + $"Unable to resolve a free PowerShell Gallery version for '{moduleName}' from expected version '{expectedVersion}' after {maxProbeCount} probes."); + } + private Version? TryResolveCurrentVersionFromPowerShellGalleryFeed(string moduleName, bool prerelease) { var versions = _powerShellGalleryFeed.GetVersions(moduleName, prerelease, timeout: TimeSpan.FromMinutes(2));