From 769f2f0cba0a834e8176d91762f20fc75fc14241 Mon Sep 17 00:00:00 2001 From: Taras Kovalenko Date: Mon, 24 Aug 2026 11:24:08 +0300 Subject: [PATCH 1/3] feat: rebuild around verified CPM migration and dependency analysis Writing Directory.Packages.props was the whole tool. It is now the first step of a workflow that can prove the migration was safe and keep the dependencies healthy afterwards. Core - Encoding-aware IO. Every project file is decoded with its encoding, BOM and line-ending style recorded, and re-encoded through the same record on write, so a CRLF UTF-8-with-BOM file comes back byte-identical apart from what actually changed. --encoding and --linewrap override when you want normalisation instead. - XML is edited surgically with LoadOptions.PreserveWhitespace rather than re-serialised, so comments, indentation and attribute order survive. - Discovery understands .sln, .slnx and .slnf, and C#, F# and VB projects, with regex directory exclusion on top of the usual bin/obj skips. - Migration planning happens entirely in memory and returns a plan holding the original and new content of every file. Dry-run prints it, --diff diffs it, apply writes it. There is no second code path that could disagree with the preview. - Backups before every write, including a record of files the operation created so a restore removes them again. List, restore, retention, prune. - Verification restores and snapshots the resolved package graph before and after the migration and diffs the two, rolling back when anything moved. This is the difference between "the files look right" and "the build produces the same binaries". - Analysis engine with 16 rules, per-rule severity overrides, package ignores, baselines and a 0-100 health score normalised for workspace size. Auto-fixes are planned as one batch and backed up like any other write. - Package updates run the test suite and roll back on failure. --bisect keeps the largest subset that still passes and names what it held back. - Feed lookups go through every source in nuget.config, so private feeds work, and cover versions, deprecation, licence and advisories. Property hoisting A property only moves into Directory.Build.props when every project that declares it uses the same value, and identity properties like AssemblyName are never touched. Requiring unanimity is what makes the hoist behaviour-preserving; conditioned PropertyGroups are left alone entirely. CLI Rebuilt on Spectre.Console.Cli. migrate, build, all, revert, analyze, update, verify, tree, status, doctor, init, explain, batch, backups and completions, with exit codes 0-9 so CI can branch on the outcome. packages, convert and packages-enhanced alias migrate. Reports render as terminal output, JSON, SARIF 2.1.0, Markdown or CSV. Build Multi-targets net8.0, net9.0 and net10.0. global.json now rolls forward to the latest installed major instead of pinning 9.0.0, which no longer resolves on a machine with only the .NET 10 SDK. 334 tests. --- .centralconfig.json | 18 + .gitignore | 5 +- .../Analysis/AnalysisEngineTests.cs | 357 ++++++++++++ .../Analysis/AnalysisReportTests.cs | 88 +++ .../Analysis/AutoFixServiceTests.cs | 177 ++++++ .../Analysis/BaselineServiceTests.cs | 68 +++ .../Analysis/ConfigurationLoaderTests.cs | 131 +++++ .../CentralConfigGenerator.Core.Tests.csproj | 2 + .../Discovery/ProjectDiscoveryServiceTests.cs | 136 +++++ .../Discovery/SolutionReaderTests.cs | 103 ++++ .../IO/EncodingDetectorTests.cs | 95 ++++ .../Migration/MigrationServiceTests.cs | 252 +++++++++ .../Migration/PackagesPropsEditorTests.cs | 74 +++ .../Migration/PackagesPropsWriterTests.cs | 123 +++++ .../Migration/ProjectRewriterTests.cs | 185 +++++++ .../Migration/PropertyHoisterTests.cs | 86 +++ .../Reporting/ReportWriterTests.cs | 146 +++++ .../Services/BackupServiceTests.cs | 156 ++++++ .../Services/UnifiedDiffTests.cs | 42 ++ .../Services/VersionSelectorTests.cs | 100 ++++ .../TestSupport/FakeDotNetCli.cs | 75 +++ .../TestSupport/FakeNuGetFeed.cs | 88 +++ .../TestSupport/InMemoryFileSystem.cs | 142 +++++ .../TestSupport/Sample.cs | 46 ++ .../Workspace/BatchServiceTests.cs | 121 +++++ .../Workspace/DependencyTreeServiceTests.cs | 80 +++ .../Workspace/PackageGraphServiceTests.cs | 105 ++++ .../Workspace/PackageUpdateServiceTests.cs | 204 +++++++ .../Workspace/VerificationServiceTests.cs | 76 +++ .../Xml/PackageReferenceReaderTests.cs | 117 ++++ .../Analysis/AnalysisContext.cs | 62 +++ .../Analysis/AnalysisEngine.cs | 353 ++++++++++++ .../Analysis/AnalysisOptions.cs | 45 ++ .../Analysis/AnalysisReport.cs | 71 +++ .../Analysis/AutoFixService.cs | 267 +++++++++ .../Analysis/BaselineService.cs | 88 +++ .../Analysis/Finding.cs | 64 +++ .../Analysis/IAnalysisRule.cs | 27 + .../Analysis/RuleCatalog.cs | 31 ++ .../Analysis/RuleIds.cs | 42 ++ .../Analysis/Rules/DeprecatedPackageRule.cs | 48 ++ .../Rules/DuplicatePackageCasingRule.cs | 52 ++ .../Analysis/Rules/FloatingVersionRule.cs | 66 +++ .../Analysis/Rules/FrameworkAlignmentRule.cs | 68 +++ .../Rules/InlineVersionUnderCpmRule.cs | 55 ++ .../Analysis/Rules/LicenseRiskRule.cs | 106 ++++ .../Rules/MissingCentralVersionRule.cs | 54 ++ .../Rules/OrphanedPackageVersionRule.cs | 48 ++ .../Analysis/Rules/OutdatedPackageRule.cs | 71 +++ .../Rules/PrereleaseInProductionRule.cs | 57 ++ .../Analysis/Rules/PropertyDriftRule.cs | 48 ++ .../Analysis/Rules/RedundantReferenceRule.cs | 72 +++ .../Rules/SecurityVulnerabilityRule.cs | 90 ++++ .../Analysis/Rules/TransitiveConflictRule.cs | 62 +++ .../Rules/UnpinnedTransitiveDependencyRule.cs | 101 ++++ .../Rules/VersionInconsistencyRule.cs | 60 +++ .../Analysis/Severity.cs | 13 + .../CentralConfigGenerator.Core.csproj | 11 +- .../Configuration/CentralConfigSettings.cs | 67 +++ .../Configuration/ConfigurationLoader.cs | 119 ++++ .../Discovery/DiscoveryOptions.cs | 52 ++ .../Discovery/IProjectDiscoveryService.cs | 15 + .../Discovery/ProjectDiscoveryService.cs | 183 +++++++ .../Discovery/SolutionReader.cs | 130 +++++ .../Generators/BuildPropsWriter.cs | 116 ++++ .../Generators/PackagesPropsEditor.cs | 171 ++++++ .../Generators/PackagesPropsWriter.cs | 258 +++++++++ .../IO/EncodingDetector.cs | 103 ++++ CentralConfigGenerator.Core/IO/IFileSystem.cs | 114 ++++ .../IO/TextFileFormat.cs | 44 ++ .../Migration/IMigrationService.cs | 26 + .../Migration/MigrationOptions.cs | 110 ++++ .../Migration/MigrationPlan.cs | 75 +++ .../Migration/MigrationService.cs | 452 ++++++++++++++++ .../Migration/ProjectRewriter.cs | 328 +++++++++++ .../Migration/PropertyHoister.cs | 100 ++++ .../Models/BackupSet.cs | 44 ++ .../Models/ExitCode.cs | 32 ++ .../Models/LineEndingStyle.cs | 82 +++ .../Models/PackageReferenceInfo.cs | 26 + .../Models/ProjectFile.cs | 25 +- .../Models/ProjectKind.cs | 31 ++ .../NuGet/INuGetFeedService.cs | 21 + .../NuGet/NuGetFeedService.cs | 234 ++++++++ .../NuGet/PackageGraph.cs | 63 +++ .../NuGet/PackageGraphService.cs | 162 ++++++ .../NuGet/PackageMetadataSnapshot.cs | 55 ++ .../Reporting/CsvReportWriter.cs | 43 ++ .../Reporting/IReportWriter.cs | 23 + .../Reporting/JsonReportWriter.cs | 69 +++ .../Reporting/MarkdownReportWriter.cs | 113 ++++ .../Reporting/ReportFormat.cs | 11 + .../Reporting/ReportPaths.cs | 34 ++ .../Reporting/SarifReportWriter.cs | 111 ++++ .../Services/Abstractions/IBackupService.cs | 37 ++ .../Services/BackupService.cs | 275 ++++++++++ .../Services/DotNetCliService.cs | 245 +++++++++ .../Services/UnifiedDiff.cs | 203 +++++++ .../Services/VersionConflictException.cs | 16 + .../Services/VersionConflictResolver.cs | 14 + .../Services/VersionSelector.cs | 90 ++++ .../Workspace/BatchService.cs | 190 +++++++ .../Workspace/DependencyTreeService.cs | 118 ++++ .../Workspace/DoctorService.cs | 411 ++++++++++++++ .../Workspace/PackageUpdateService.cs | 508 ++++++++++++++++++ .../Workspace/StatusService.cs | 105 ++++ .../Workspace/VerificationService.cs | 167 ++++++ CentralConfigGenerator.Core/Xml/MsBuildXml.cs | 156 ++++++ .../Xml/PackageReferenceReader.cs | 107 ++++ .../CentralConfigGenerator.Tests.csproj | 2 + .../Cli/CompletionsCommandTests.cs | 50 ++ .../Cli/EffectiveSettingsTests.cs | 140 +++++ .../Cli/ServiceRegistrationTests.cs | 63 +++ .../Cli/WorkspaceSettingsTests.cs | 58 ++ .../CentralConfigGenerator.csproj | 21 +- .../Cli/Commands/AllCommand.cs | 53 ++ .../Cli/Commands/AnalyzeCommand.cs | 290 ++++++++++ .../Cli/Commands/BackupCommands.cs | 190 +++++++ .../Cli/Commands/BatchCommand.cs | 159 ++++++ .../Cli/Commands/BuildPropsCommand.cs | 127 +++++ .../Cli/Commands/CompletionsCommand.cs | 181 +++++++ .../Cli/Commands/DoctorCommand.cs | 65 +++ .../Cli/Commands/ExplainCommand.cs | 84 +++ .../Cli/Commands/InitCommand.cs | 47 ++ .../Cli/Commands/MigrateCommand.cs | 253 +++++++++ .../Cli/Commands/RevertCommand.cs | 102 ++++ .../Cli/Commands/StatusCommand.cs | 120 +++++ .../Cli/Commands/TreeCommand.cs | 49 ++ .../Cli/Commands/UpdateCommand.cs | 134 +++++ .../Cli/Commands/VerifyCommand.cs | 178 ++++++ .../Cli/Infrastructure/CommandSettingsBase.cs | 111 ++++ .../Cli/Infrastructure/EffectiveSettings.cs | 129 +++++ .../Cli/Infrastructure/TypeRegistrar.cs | 26 + .../Cli/Infrastructure/Ui.cs | 103 ++++ .../Cli/Rendering/ReportRenderer.cs | 235 ++++++++ CentralConfigGenerator/Program.cs | 343 +++++++----- Directory.Build.props | 6 +- Directory.Packages.props | 1 - global.json | 4 +- 139 files changed, 14995 insertions(+), 143 deletions(-) create mode 100644 .centralconfig.json create mode 100644 CentralConfigGenerator.Core.Tests/Analysis/AnalysisEngineTests.cs create mode 100644 CentralConfigGenerator.Core.Tests/Analysis/AnalysisReportTests.cs create mode 100644 CentralConfigGenerator.Core.Tests/Analysis/AutoFixServiceTests.cs create mode 100644 CentralConfigGenerator.Core.Tests/Analysis/BaselineServiceTests.cs create mode 100644 CentralConfigGenerator.Core.Tests/Analysis/ConfigurationLoaderTests.cs create mode 100644 CentralConfigGenerator.Core.Tests/Discovery/ProjectDiscoveryServiceTests.cs create mode 100644 CentralConfigGenerator.Core.Tests/Discovery/SolutionReaderTests.cs create mode 100644 CentralConfigGenerator.Core.Tests/IO/EncodingDetectorTests.cs create mode 100644 CentralConfigGenerator.Core.Tests/Migration/MigrationServiceTests.cs create mode 100644 CentralConfigGenerator.Core.Tests/Migration/PackagesPropsEditorTests.cs create mode 100644 CentralConfigGenerator.Core.Tests/Migration/PackagesPropsWriterTests.cs create mode 100644 CentralConfigGenerator.Core.Tests/Migration/ProjectRewriterTests.cs create mode 100644 CentralConfigGenerator.Core.Tests/Migration/PropertyHoisterTests.cs create mode 100644 CentralConfigGenerator.Core.Tests/Reporting/ReportWriterTests.cs create mode 100644 CentralConfigGenerator.Core.Tests/Services/BackupServiceTests.cs create mode 100644 CentralConfigGenerator.Core.Tests/Services/UnifiedDiffTests.cs create mode 100644 CentralConfigGenerator.Core.Tests/Services/VersionSelectorTests.cs create mode 100644 CentralConfigGenerator.Core.Tests/TestSupport/FakeDotNetCli.cs create mode 100644 CentralConfigGenerator.Core.Tests/TestSupport/FakeNuGetFeed.cs create mode 100644 CentralConfigGenerator.Core.Tests/TestSupport/InMemoryFileSystem.cs create mode 100644 CentralConfigGenerator.Core.Tests/TestSupport/Sample.cs create mode 100644 CentralConfigGenerator.Core.Tests/Workspace/BatchServiceTests.cs create mode 100644 CentralConfigGenerator.Core.Tests/Workspace/DependencyTreeServiceTests.cs create mode 100644 CentralConfigGenerator.Core.Tests/Workspace/PackageGraphServiceTests.cs create mode 100644 CentralConfigGenerator.Core.Tests/Workspace/PackageUpdateServiceTests.cs create mode 100644 CentralConfigGenerator.Core.Tests/Workspace/VerificationServiceTests.cs create mode 100644 CentralConfigGenerator.Core.Tests/Xml/PackageReferenceReaderTests.cs create mode 100644 CentralConfigGenerator.Core/Analysis/AnalysisContext.cs create mode 100644 CentralConfigGenerator.Core/Analysis/AnalysisEngine.cs create mode 100644 CentralConfigGenerator.Core/Analysis/AnalysisOptions.cs create mode 100644 CentralConfigGenerator.Core/Analysis/AnalysisReport.cs create mode 100644 CentralConfigGenerator.Core/Analysis/AutoFixService.cs create mode 100644 CentralConfigGenerator.Core/Analysis/BaselineService.cs create mode 100644 CentralConfigGenerator.Core/Analysis/Finding.cs create mode 100644 CentralConfigGenerator.Core/Analysis/IAnalysisRule.cs create mode 100644 CentralConfigGenerator.Core/Analysis/RuleCatalog.cs create mode 100644 CentralConfigGenerator.Core/Analysis/RuleIds.cs create mode 100644 CentralConfigGenerator.Core/Analysis/Rules/DeprecatedPackageRule.cs create mode 100644 CentralConfigGenerator.Core/Analysis/Rules/DuplicatePackageCasingRule.cs create mode 100644 CentralConfigGenerator.Core/Analysis/Rules/FloatingVersionRule.cs create mode 100644 CentralConfigGenerator.Core/Analysis/Rules/FrameworkAlignmentRule.cs create mode 100644 CentralConfigGenerator.Core/Analysis/Rules/InlineVersionUnderCpmRule.cs create mode 100644 CentralConfigGenerator.Core/Analysis/Rules/LicenseRiskRule.cs create mode 100644 CentralConfigGenerator.Core/Analysis/Rules/MissingCentralVersionRule.cs create mode 100644 CentralConfigGenerator.Core/Analysis/Rules/OrphanedPackageVersionRule.cs create mode 100644 CentralConfigGenerator.Core/Analysis/Rules/OutdatedPackageRule.cs create mode 100644 CentralConfigGenerator.Core/Analysis/Rules/PrereleaseInProductionRule.cs create mode 100644 CentralConfigGenerator.Core/Analysis/Rules/PropertyDriftRule.cs create mode 100644 CentralConfigGenerator.Core/Analysis/Rules/RedundantReferenceRule.cs create mode 100644 CentralConfigGenerator.Core/Analysis/Rules/SecurityVulnerabilityRule.cs create mode 100644 CentralConfigGenerator.Core/Analysis/Rules/TransitiveConflictRule.cs create mode 100644 CentralConfigGenerator.Core/Analysis/Rules/UnpinnedTransitiveDependencyRule.cs create mode 100644 CentralConfigGenerator.Core/Analysis/Rules/VersionInconsistencyRule.cs create mode 100644 CentralConfigGenerator.Core/Analysis/Severity.cs create mode 100644 CentralConfigGenerator.Core/Configuration/CentralConfigSettings.cs create mode 100644 CentralConfigGenerator.Core/Configuration/ConfigurationLoader.cs create mode 100644 CentralConfigGenerator.Core/Discovery/DiscoveryOptions.cs create mode 100644 CentralConfigGenerator.Core/Discovery/IProjectDiscoveryService.cs create mode 100644 CentralConfigGenerator.Core/Discovery/ProjectDiscoveryService.cs create mode 100644 CentralConfigGenerator.Core/Discovery/SolutionReader.cs create mode 100644 CentralConfigGenerator.Core/Generators/BuildPropsWriter.cs create mode 100644 CentralConfigGenerator.Core/Generators/PackagesPropsEditor.cs create mode 100644 CentralConfigGenerator.Core/Generators/PackagesPropsWriter.cs create mode 100644 CentralConfigGenerator.Core/IO/EncodingDetector.cs create mode 100644 CentralConfigGenerator.Core/IO/IFileSystem.cs create mode 100644 CentralConfigGenerator.Core/IO/TextFileFormat.cs create mode 100644 CentralConfigGenerator.Core/Migration/IMigrationService.cs create mode 100644 CentralConfigGenerator.Core/Migration/MigrationOptions.cs create mode 100644 CentralConfigGenerator.Core/Migration/MigrationPlan.cs create mode 100644 CentralConfigGenerator.Core/Migration/MigrationService.cs create mode 100644 CentralConfigGenerator.Core/Migration/ProjectRewriter.cs create mode 100644 CentralConfigGenerator.Core/Migration/PropertyHoister.cs create mode 100644 CentralConfigGenerator.Core/Models/BackupSet.cs create mode 100644 CentralConfigGenerator.Core/Models/ExitCode.cs create mode 100644 CentralConfigGenerator.Core/Models/LineEndingStyle.cs create mode 100644 CentralConfigGenerator.Core/Models/PackageReferenceInfo.cs create mode 100644 CentralConfigGenerator.Core/Models/ProjectKind.cs create mode 100644 CentralConfigGenerator.Core/NuGet/INuGetFeedService.cs create mode 100644 CentralConfigGenerator.Core/NuGet/NuGetFeedService.cs create mode 100644 CentralConfigGenerator.Core/NuGet/PackageGraph.cs create mode 100644 CentralConfigGenerator.Core/NuGet/PackageGraphService.cs create mode 100644 CentralConfigGenerator.Core/NuGet/PackageMetadataSnapshot.cs create mode 100644 CentralConfigGenerator.Core/Reporting/CsvReportWriter.cs create mode 100644 CentralConfigGenerator.Core/Reporting/IReportWriter.cs create mode 100644 CentralConfigGenerator.Core/Reporting/JsonReportWriter.cs create mode 100644 CentralConfigGenerator.Core/Reporting/MarkdownReportWriter.cs create mode 100644 CentralConfigGenerator.Core/Reporting/ReportFormat.cs create mode 100644 CentralConfigGenerator.Core/Reporting/ReportPaths.cs create mode 100644 CentralConfigGenerator.Core/Reporting/SarifReportWriter.cs create mode 100644 CentralConfigGenerator.Core/Services/Abstractions/IBackupService.cs create mode 100644 CentralConfigGenerator.Core/Services/BackupService.cs create mode 100644 CentralConfigGenerator.Core/Services/DotNetCliService.cs create mode 100644 CentralConfigGenerator.Core/Services/UnifiedDiff.cs create mode 100644 CentralConfigGenerator.Core/Services/VersionConflictException.cs create mode 100644 CentralConfigGenerator.Core/Services/VersionSelector.cs create mode 100644 CentralConfigGenerator.Core/Workspace/BatchService.cs create mode 100644 CentralConfigGenerator.Core/Workspace/DependencyTreeService.cs create mode 100644 CentralConfigGenerator.Core/Workspace/DoctorService.cs create mode 100644 CentralConfigGenerator.Core/Workspace/PackageUpdateService.cs create mode 100644 CentralConfigGenerator.Core/Workspace/StatusService.cs create mode 100644 CentralConfigGenerator.Core/Workspace/VerificationService.cs create mode 100644 CentralConfigGenerator.Core/Xml/MsBuildXml.cs create mode 100644 CentralConfigGenerator.Core/Xml/PackageReferenceReader.cs create mode 100644 CentralConfigGenerator.Tests/Cli/CompletionsCommandTests.cs create mode 100644 CentralConfigGenerator.Tests/Cli/EffectiveSettingsTests.cs create mode 100644 CentralConfigGenerator.Tests/Cli/ServiceRegistrationTests.cs create mode 100644 CentralConfigGenerator.Tests/Cli/WorkspaceSettingsTests.cs create mode 100644 CentralConfigGenerator/Cli/Commands/AllCommand.cs create mode 100644 CentralConfigGenerator/Cli/Commands/AnalyzeCommand.cs create mode 100644 CentralConfigGenerator/Cli/Commands/BackupCommands.cs create mode 100644 CentralConfigGenerator/Cli/Commands/BatchCommand.cs create mode 100644 CentralConfigGenerator/Cli/Commands/BuildPropsCommand.cs create mode 100644 CentralConfigGenerator/Cli/Commands/CompletionsCommand.cs create mode 100644 CentralConfigGenerator/Cli/Commands/DoctorCommand.cs create mode 100644 CentralConfigGenerator/Cli/Commands/ExplainCommand.cs create mode 100644 CentralConfigGenerator/Cli/Commands/InitCommand.cs create mode 100644 CentralConfigGenerator/Cli/Commands/MigrateCommand.cs create mode 100644 CentralConfigGenerator/Cli/Commands/RevertCommand.cs create mode 100644 CentralConfigGenerator/Cli/Commands/StatusCommand.cs create mode 100644 CentralConfigGenerator/Cli/Commands/TreeCommand.cs create mode 100644 CentralConfigGenerator/Cli/Commands/UpdateCommand.cs create mode 100644 CentralConfigGenerator/Cli/Commands/VerifyCommand.cs create mode 100644 CentralConfigGenerator/Cli/Infrastructure/CommandSettingsBase.cs create mode 100644 CentralConfigGenerator/Cli/Infrastructure/EffectiveSettings.cs create mode 100644 CentralConfigGenerator/Cli/Infrastructure/TypeRegistrar.cs create mode 100644 CentralConfigGenerator/Cli/Infrastructure/Ui.cs create mode 100644 CentralConfigGenerator/Cli/Rendering/ReportRenderer.cs diff --git a/.centralconfig.json b/.centralconfig.json new file mode 100644 index 0000000..779ceec --- /dev/null +++ b/.centralconfig.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://raw.githubusercontent.com/TarasKovalenko/CentralConfigGenerator/main/schemas/centralconfig.schema.json", + "conflictStrategy": "Highest", + "backup": true, + "addGitignore": true, + "failOn": "High", + "retention": { "enabled": true, "maxBackups": 5 }, + "rules": { + "OutdatedPackage": "Low", + "PropertyDrift": "none" + }, + "buildProperties": [ + "ImplicitUsings", + "Nullable", + "LangVersion", + "InvariantGlobalization" + ] +} diff --git a/.gitignore b/.gitignore index 48a1780..45b7508 100644 --- a/.gitignore +++ b/.gitignore @@ -399,4 +399,7 @@ FodyWeavers.xsd # JetBrains Rider *.sln.iml -.idea/ \ No newline at end of file +.idea/ + +# CentralConfigGenerator backups +.centralconfig-backups/ diff --git a/CentralConfigGenerator.Core.Tests/Analysis/AnalysisEngineTests.cs b/CentralConfigGenerator.Core.Tests/Analysis/AnalysisEngineTests.cs new file mode 100644 index 0000000..203b813 --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/Analysis/AnalysisEngineTests.cs @@ -0,0 +1,357 @@ +using CentralConfigGenerator.Core.Analysis; +using CentralConfigGenerator.Core.Discovery; +using CentralConfigGenerator.Core.NuGet; +using CentralConfigGenerator.Core.Tests.TestSupport; + +namespace CentralConfigGenerator.Core.Tests.Analysis; + +public class AnalysisEngineTests +{ + private static readonly string Root = Path.Combine(Path.GetTempPath(), "ccg-analysis"); + + [Fact] + public async Task AnalyzeAsync_ShouldReportVersionInconsistency() + { + var report = await RunAsync(WorkspaceWithTwoProjects()); + + report.Findings.ShouldContain(f => f.RuleId == RuleIds.VersionInconsistency); + report.Findings + .First(f => f.RuleId == RuleIds.VersionInconsistency && f.PackageId == "Newtonsoft.Json") + .FixValue.ShouldBe("13.0.3"); + } + + [Fact] + public async Task AnalyzeAsync_ShouldReportCasingMismatches() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "a", "A.csproj"), Reference("Newtonsoft.Json", "13.0.3")) + .AddFile(Path.Combine(Root, "b", "B.csproj"), Reference("newtonsoft.json", "13.0.3")); + + var report = await RunAsync(fileSystem); + + report.Findings.ShouldContain(f => f.RuleId == RuleIds.DuplicatePackageCasing); + } + + [Fact] + public async Task AnalyzeAsync_ShouldReportFloatingVersions() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "a", "A.csproj"), Reference("xunit", "2.9.*")); + + var report = await RunAsync(fileSystem); + + report.Findings.ShouldContain(f => f.RuleId == RuleIds.FloatingVersion); + } + + [Fact] + public async Task AnalyzeAsync_ShouldReportDuplicateReferencesInOneProject() + { + var content = """ + + + + + + + """; + + var fileSystem = new InMemoryFileSystem().AddFile(Path.Combine(Root, "A.csproj"), content); + + var report = await RunAsync(fileSystem); + + report.Findings.ShouldContain(f => f.RuleId == RuleIds.RedundantReference); + } + + [Fact] + public async Task AnalyzeAsync_ShouldNotFlagConditionedReferencesAsDuplicates() + { + var content = """ + + + + + + + + + """; + + var fileSystem = new InMemoryFileSystem().AddFile(Path.Combine(Root, "A.csproj"), content); + + var report = await RunAsync(fileSystem); + + report.Findings.ShouldNotContain(f => f.RuleId == RuleIds.RedundantReference); + } + + [Fact] + public async Task AnalyzeAsync_ShouldReportInlineVersionsUnderCentralManagement() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "A.csproj"), Reference("Serilog", "2.0.0")) + .AddFile(Path.Combine(Root, "Directory.Packages.props"), Sample.PackagesProps); + + var report = await RunAsync(fileSystem); + + var finding = report.Findings.First(f => f.RuleId == RuleIds.InlineVersionUnderCpm); + finding.Severity.ShouldBe(Severity.High); + finding.Fix.ShouldBe(FixKind.RemoveInlineVersion); + } + + [Fact] + public async Task AnalyzeAsync_ShouldReportOrphanedCentralEntries() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "A.csproj"), Reference("Serilog", null)) + .AddFile(Path.Combine(Root, "Directory.Packages.props"), Sample.PackagesProps); + + var report = await RunAsync(fileSystem); + + report.Findings.ShouldContain(f => + f.RuleId == RuleIds.OrphanedPackageVersion && f.PackageId == "Newtonsoft.Json" + ); + } + + [Fact] + public async Task AnalyzeAsync_ShouldReportPackagesWithNoVersionAtAll() + { + var props = """ + + + true + + + """; + + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "A.csproj"), Reference("Serilog", null)) + .AddFile(Path.Combine(Root, "Directory.Packages.props"), props); + + var report = await RunAsync(fileSystem); + + report.Findings.ShouldContain(f => f.RuleId == RuleIds.MissingCentralVersion); + } + + [Fact] + public async Task AnalyzeAsync_ShouldSkipNetworkRulesWhenTheyAreNotRequested() + { + var report = await RunAsync(WorkspaceWithTwoProjects()); + + report.Findings.ShouldNotContain(f => f.RuleId == RuleIds.OutdatedPackage); + report.Findings.ShouldNotContain(f => f.RuleId == RuleIds.SecurityVulnerability); + } + + [Fact] + public async Task AnalyzeAsync_ShouldReportVulnerabilitiesWhenAuditingIsOn() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "A.csproj"), Reference("Vulnerable.Pkg", "1.0.0")); + + var feed = new FakeNuGetFeed().Add( + "Vulnerable.Pkg", + ["1.0.0", "1.0.1"], + vulnerabilities: [("1.0.0", Severity.Critical)] + ); + + var report = await RunAsync(fileSystem, feed, new AnalysisOptions { RootDirectory = Root, Audit = true }); + + var finding = report.Findings.First(f => f.RuleId == RuleIds.SecurityVulnerability); + finding.Severity.ShouldBe(Severity.Critical); + finding.FixValue.ShouldBe("1.0.1"); + } + + [Fact] + public async Task AnalyzeAsync_ShouldReportDeprecatedPackages() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "A.csproj"), Reference("Old.Pkg", "1.0.0")); + + var feed = new FakeNuGetFeed().Add("Old.Pkg", ["1.0.0"], deprecated: true, alternate: "New.Pkg"); + + var report = await RunAsync( + fileSystem, + feed, + new AnalysisOptions { RootDirectory = Root, Deprecated = true } + ); + + report.Findings + .First(f => f.RuleId == RuleIds.DeprecatedPackage) + .Recommendation.ShouldNotBeNull() + .ShouldContain("New.Pkg"); + } + + [Fact] + public async Task AnalyzeAsync_ShouldGradeOutdatedPackagesByHowFarBehindTheyAre() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "A.csproj"), Reference("Old.Pkg", "1.0.0")); + + var feed = new FakeNuGetFeed().Add("Old.Pkg", ["1.0.0", "3.0.0"]); + + var report = await RunAsync( + fileSystem, + feed, + new AnalysisOptions { RootDirectory = Root, Outdated = true } + ); + + report.Findings.First(f => f.RuleId == RuleIds.OutdatedPackage).Severity + .ShouldBe(Severity.Moderate); + } + + [Fact] + public async Task AnalyzeAsync_ShouldFlagCopyleftLicences() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "A.csproj"), Reference("Gpl.Pkg", "1.0.0")); + + var feed = new FakeNuGetFeed().Add("Gpl.Pkg", ["1.0.0"], license: "GPL-3.0-only"); + + var report = await RunAsync( + fileSystem, + feed, + new AnalysisOptions { RootDirectory = Root, Licenses = true } + ); + + report.Findings.First(f => f.RuleId == RuleIds.LicenseRisk).Severity.ShouldBe(Severity.High); + } + + [Fact] + public async Task AnalyzeAsync_ShouldHonourRuleSeverityOverrides() + { + var options = new AnalysisOptions + { + RootDirectory = Root, + RuleOverrides = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [RuleIds.VersionInconsistency] = Severity.Critical, + }, + }; + + var report = await RunAsync(WorkspaceWithTwoProjects(), options: options); + + report.Findings + .First(f => f.RuleId == RuleIds.VersionInconsistency) + .Severity.ShouldBe(Severity.Critical); + } + + [Fact] + public async Task AnalyzeAsync_ShouldSkipDisabledRules() + { + var options = new AnalysisOptions + { + RootDirectory = Root, + RuleOverrides = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [RuleIds.VersionInconsistency] = null, + }, + }; + + var report = await RunAsync(WorkspaceWithTwoProjects(), options: options); + + report.Findings.ShouldNotContain(f => f.RuleId == RuleIds.VersionInconsistency); + } + + [Fact] + public async Task AnalyzeAsync_ShouldIgnoreListedPackages() + { + var options = new AnalysisOptions { RootDirectory = Root, IgnorePackages = ["Newtonsoft.*"] }; + + var report = await RunAsync(WorkspaceWithTwoProjects(), options: options); + + report.Findings.ShouldNotContain(f => f.PackageId == "Newtonsoft.Json"); + } + + [Fact] + public async Task AnalyzeAsync_ShouldScoreACleanWorkspaceAt100() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "A.csproj"), Reference("Serilog", "3.1.1")); + + var report = await RunAsync(fileSystem); + + report.Findings.ShouldBeEmpty(); + report.HealthScore.ShouldBe(100); + report.Grade.ShouldBe("A"); + } + + [Fact] + public async Task AnalyzeAsync_ShouldReturnAnEmptyReportWhenThereAreNoProjects() + { + var report = await RunAsync(new InMemoryFileSystem().AddDirectory(Root)); + + report.ProjectCount.ShouldBe(0); + report.Findings.ShouldBeEmpty(); + } + + [Fact] + public async Task AnalyzeAsync_ShouldReportTransitiveDivergence() + { + var graph = new PackageGraph + { + IsAvailable = true, + Packages = + [ + new ResolvedPackage + { + PackageId = "System.Text.Json", + ResolvedVersion = "8.0.0", + ProjectPath = "A.csproj", + TargetFramework = "net8.0", + IsTransitive = true, + }, + new ResolvedPackage + { + PackageId = "System.Text.Json", + ResolvedVersion = "9.0.0", + ProjectPath = "B.csproj", + TargetFramework = "net8.0", + IsTransitive = true, + }, + ], + }; + + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "A.csproj"), Reference("Serilog", "3.1.1")); + + var engine = CreateEngine(fileSystem, new FakeNuGetFeed(), graph); + var report = await engine.AnalyzeAsync( + new AnalysisOptions { RootDirectory = Root, Transitive = true } + ); + + report.Findings.ShouldContain(f => f.RuleId == RuleIds.TransitiveConflict); + } + + private static InMemoryFileSystem WorkspaceWithTwoProjects() => + new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "a", "A.csproj"), Reference("Newtonsoft.Json", "13.0.1")) + .AddFile(Path.Combine(Root, "b", "B.csproj"), Reference("Newtonsoft.Json", "13.0.3")); + + private static string Reference(string packageId, string? version) => + $""" + + + + + + """; + + private static AnalysisEngine CreateEngine( + InMemoryFileSystem fileSystem, + INuGetFeedService feed, + PackageGraph? graph = null + ) => + new( + new ProjectDiscoveryService(fileSystem), + fileSystem, + feed, + new FakePackageGraphService(graph ?? PackageGraph.Empty), + new BaselineService(fileSystem) + ); + + private static Task RunAsync( + InMemoryFileSystem fileSystem, + INuGetFeedService? feed = null, + AnalysisOptions? options = null + ) => + CreateEngine(fileSystem, feed ?? new FakeNuGetFeed()) + .AnalyzeAsync(options ?? new AnalysisOptions { RootDirectory = Root }); +} diff --git a/CentralConfigGenerator.Core.Tests/Analysis/AnalysisReportTests.cs b/CentralConfigGenerator.Core.Tests/Analysis/AnalysisReportTests.cs new file mode 100644 index 0000000..0efa26f --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/Analysis/AnalysisReportTests.cs @@ -0,0 +1,88 @@ +using CentralConfigGenerator.Core.Analysis; + +namespace CentralConfigGenerator.Core.Tests.Analysis; + +public class AnalysisReportTests +{ + [Fact] + public void HealthScore_ShouldBe100ForACleanWorkspace() => + Report([]).HealthScore.ShouldBe(100); + + [Fact] + public void HealthScore_ShouldDropSharplyForCriticalFindings() + { + var report = Report([Finding(Severity.Critical)]); + + report.HealthScore.ShouldBeLessThan(80); + report.Grade.ShouldNotBe("A"); + } + + [Fact] + public void HealthScore_ShouldBarelyMoveForInfoFindings() => + Report([Finding(Severity.Info)]).HealthScore.ShouldBeGreaterThan(95); + + [Fact] + public void HealthScore_ShouldNeverGoBelowZero() + { + var findings = Enumerable.Repeat(Finding(Severity.Critical), 100).ToList(); + + Report(findings).HealthScore.ShouldBe(0); + } + + [Fact] + public void HealthScore_ShouldScaleWithWorkspaceSize() + { + var findings = Enumerable.Repeat(Finding(Severity.Moderate), 5).ToList(); + + var small = Report(findings, packageCount: 5); + var large = Report(findings, packageCount: 500); + + large.HealthScore.ShouldBeGreaterThan(small.HealthScore); + } + + [Fact] + public void HighestSeverity_ShouldReturnTheWorstFinding() => + Report([Finding(Severity.Low), Finding(Severity.High)]).HighestSeverity + .ShouldBe(Severity.High); + + [Fact] + public void HighestSeverity_ShouldBeNeverWhenThereAreNoFindings() => + Report([]).HighestSeverity.ShouldBe(Severity.Never); + + [Theory] + [InlineData(100, "A")] + [InlineData(85, "B")] + [InlineData(75, "C")] + [InlineData(65, "D")] + [InlineData(10, "F")] + public void Grade_ShouldFollowTheScore(int targetScore, string expected) + { + // Work backwards: pick a finding count that lands near the target score. + var report = Report([], packageCount: 9); + var findings = new List(); + + while (Report(findings, packageCount: 9).HealthScore > targetScore) + { + findings.Add(Finding(Severity.Low)); + } + + report = Report(findings, packageCount: 9); + report.Grade.ShouldBe(expected); + } + + private static Finding Finding(Severity severity) => + new() + { + RuleId = "X", + Severity = severity, + Message = "m", + }; + + private static AnalysisReport Report(IReadOnlyList findings, int packageCount = 9) => + new() + { + Findings = findings, + ProjectCount = 3, + PackageCount = packageCount, + }; +} diff --git a/CentralConfigGenerator.Core.Tests/Analysis/AutoFixServiceTests.cs b/CentralConfigGenerator.Core.Tests/Analysis/AutoFixServiceTests.cs new file mode 100644 index 0000000..ec3ff78 --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/Analysis/AutoFixServiceTests.cs @@ -0,0 +1,177 @@ +using CentralConfigGenerator.Core.Analysis; +using CentralConfigGenerator.Core.Discovery; +using CentralConfigGenerator.Core.NuGet; +using CentralConfigGenerator.Core.Services; +using CentralConfigGenerator.Core.Tests.TestSupport; + +namespace CentralConfigGenerator.Core.Tests.Analysis; + +public class AutoFixServiceTests +{ + private static readonly string Root = Path.Combine(Path.GetTempPath(), "ccg-autofix"); + + [Fact] + public async Task PlanAsync_ShouldRemoveInlineVersionsUnderCentralManagement() + { + var project = Path.Combine(Root, "A.csproj"); + + var fileSystem = new InMemoryFileSystem() + .AddFile(project, Sample.AppProject) + .AddFile(Path.Combine(Root, "Directory.Packages.props"), Sample.PackagesProps); + + var (service, context) = await CreateAsync(fileSystem); + + var result = await service.PlanAsync( + context, + context.References.Select(r => new Finding + { + RuleId = RuleIds.InlineVersionUnderCpm, + Severity = Severity.High, + Message = "inline", + PackageId = r.PackageId, + ProjectPath = r.ProjectPath, + Fix = FixKind.RemoveInlineVersion, + }).ToList() + ); + + result.Changes.ShouldHaveSingleItem(); + result.Changes[0].NewContent.ShouldNotContain("Version="); + result.Fixed.Count.ShouldBe(2); + } + + [Fact] + public async Task PlanAsync_ShouldSetCentralVersions() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "A.csproj"), Sample.AppProject) + .AddFile(Path.Combine(Root, "Directory.Packages.props"), Sample.PackagesProps); + + var (service, context) = await CreateAsync(fileSystem); + + var result = await service.PlanAsync( + context, + [ + new Finding + { + RuleId = RuleIds.OutdatedPackage, + Severity = Severity.Low, + Message = "outdated", + PackageId = "Serilog", + Fix = FixKind.SetCentralVersion, + FixValue = "4.0.0", + }, + ] + ); + + result.Changes.ShouldHaveSingleItem(); + result.Changes[0].NewContent.ShouldContain("Version=\"4.0.0\""); + } + + [Fact] + public async Task PlanAsync_ShouldStackSeveralFixesOnOneFile() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "A.csproj"), Sample.AppProject) + .AddFile(Path.Combine(Root, "Directory.Packages.props"), Sample.PackagesProps); + + var (service, context) = await CreateAsync(fileSystem); + + var result = await service.PlanAsync( + context, + [ + Fix("Serilog", "4.0.0"), + Fix("Newtonsoft.Json", "14.0.0"), + ] + ); + + result.Changes.ShouldHaveSingleItem(); + result.Changes[0].NewContent.ShouldContain("4.0.0"); + result.Changes[0].NewContent.ShouldContain("14.0.0"); + + static Finding Fix(string packageId, string version) => + new() + { + RuleId = RuleIds.OutdatedPackage, + Severity = Severity.Low, + Message = "outdated", + PackageId = packageId, + Fix = FixKind.SetCentralVersion, + FixValue = version, + }; + } + + [Fact] + public async Task PlanAsync_ShouldSkipFindingsThatAreNotAutoFixable() + { + var fileSystem = new InMemoryFileSystem().AddFile(Path.Combine(Root, "A.csproj"), Sample.AppProject); + + var (service, context) = await CreateAsync(fileSystem); + + var result = await service.PlanAsync( + context, + [ + new Finding + { + RuleId = RuleIds.FloatingVersion, + Severity = Severity.Moderate, + Message = "floating", + }, + ] + ); + + result.Skipped.ShouldHaveSingleItem(); + result.HasChanges.ShouldBeFalse(); + } + + [Fact] + public async Task ApplyAsync_ShouldWriteChangesAndBackThemUp() + { + var project = Path.Combine(Root, "A.csproj"); + + var fileSystem = new InMemoryFileSystem() + .AddFile(project, Sample.AppProject) + .AddFile(Path.Combine(Root, "Directory.Packages.props"), Sample.PackagesProps); + + var (service, context) = await CreateAsync(fileSystem); + + var result = await service.PlanAsync( + context, + [ + new Finding + { + RuleId = RuleIds.InlineVersionUnderCpm, + Severity = Severity.High, + Message = "inline", + PackageId = "Serilog", + ProjectPath = project, + Fix = FixKind.RemoveInlineVersion, + }, + ] + ); + + var backup = await service.ApplyAsync(result, Root, Root, createBackup: true); + + fileSystem.ReadText(project).ShouldContain(""); + backup.ShouldNotBeNull(); + } + + private static async Task<(AutoFixService Service, AnalysisContext Context)> CreateAsync( + InMemoryFileSystem fileSystem + ) + { + var discovery = new ProjectDiscoveryService(fileSystem); + + var engine = new AnalysisEngine( + discovery, + fileSystem, + new FakeNuGetFeed(), + new FakePackageGraphService(PackageGraph.Empty), + new BaselineService(fileSystem) + ); + + var context = await engine.BuildContextAsync(new AnalysisOptions { RootDirectory = Root }); + var service = new AutoFixService(discovery, fileSystem, new BackupService(fileSystem)); + + return (service, context); + } +} diff --git a/CentralConfigGenerator.Core.Tests/Analysis/BaselineServiceTests.cs b/CentralConfigGenerator.Core.Tests/Analysis/BaselineServiceTests.cs new file mode 100644 index 0000000..3ec2111 --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/Analysis/BaselineServiceTests.cs @@ -0,0 +1,68 @@ +using CentralConfigGenerator.Core.Analysis; +using CentralConfigGenerator.Core.Tests.TestSupport; + +namespace CentralConfigGenerator.Core.Tests.Analysis; + +public class BaselineServiceTests +{ + private static readonly string Path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + "ccg-baseline.json" + ); + + private static readonly Finding Sample = new() + { + RuleId = RuleIds.FloatingVersion, + Severity = Severity.Moderate, + Message = "floating", + PackageId = "xunit", + Version = "2.9.*", + }; + + [Fact] + public async Task WriteAsync_ThenLoadAsync_ShouldRoundTrip() + { + var fileSystem = new InMemoryFileSystem(); + var service = new BaselineService(fileSystem); + + await service.WriteAsync(Path, [Sample]); + var baseline = await service.LoadAsync(Path); + + baseline.ShouldNotBeNull(); + baseline.Fingerprints.ShouldContain(Sample.Fingerprint); + } + + [Fact] + public async Task LoadAsync_ShouldReturnNullWhenTheFileIsMissing() => + (await new BaselineService(new InMemoryFileSystem()).LoadAsync(Path)).ShouldBeNull(); + + [Fact] + public async Task LoadAsync_ShouldReturnNullForMalformedJson() + { + var fileSystem = new InMemoryFileSystem().AddFile(Path, "{ not json"); + + (await new BaselineService(fileSystem).LoadAsync(Path)).ShouldBeNull(); + } + + [Fact] + public void Apply_ShouldSuppressRecordedFindings() + { + var service = new BaselineService(new InMemoryFileSystem()); + var baseline = new Baseline { Fingerprints = [Sample.Fingerprint] }; + + service.Apply([Sample], baseline).ShouldBeEmpty(); + } + + [Fact] + public void Apply_ShouldKeepFindingsThatAreNotInTheBaseline() + { + var service = new BaselineService(new InMemoryFileSystem()); + var baseline = new Baseline { Fingerprints = ["Something|else|-|-"] }; + + service.Apply([Sample], baseline).ShouldHaveSingleItem(); + } + + [Fact] + public void Apply_ShouldReturnEverythingWhenThereIsNoBaseline() => + new BaselineService(new InMemoryFileSystem()).Apply([Sample], null).ShouldHaveSingleItem(); +} diff --git a/CentralConfigGenerator.Core.Tests/Analysis/ConfigurationLoaderTests.cs b/CentralConfigGenerator.Core.Tests/Analysis/ConfigurationLoaderTests.cs new file mode 100644 index 0000000..08b7ceb --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/Analysis/ConfigurationLoaderTests.cs @@ -0,0 +1,131 @@ +using CentralConfigGenerator.Core.Analysis; +using CentralConfigGenerator.Core.Configuration; +using CentralConfigGenerator.Core.Tests.TestSupport; + +namespace CentralConfigGenerator.Core.Tests.Analysis; + +public class ConfigurationLoaderTests +{ + private static readonly string Root = Path.Combine(Path.GetTempPath(), "ccg-config"); + + [Fact] + public async Task LoadAsync_ShouldReadEveryKnownSetting() + { + var json = """ + { + "conflictStrategy": "Lowest", + "backup": false, + "failOn": "Critical", + "transitivePinning": true, + "excludeDirs": "samples", + "rules": { "OutdatedPackage": "none" }, + "retention": { "enabled": true, "maxBackups": 3 }, + "ignorePackages": [ "Internal.*" ] + } + """; + + var fileSystem = new InMemoryFileSystem().AddFile( + Path.Combine(Root, ConfigurationLoader.FileName), + json + ); + + var (settings, path, error) = await new ConfigurationLoader(fileSystem).LoadAsync(Root); + + error.ShouldBeNull(); + path.ShouldNotBeNull(); + settings.ShouldNotBeNull(); + settings.ConflictStrategy.ShouldBe(VersionResolutionStrategyName.Lowest); + settings.Backup.ShouldBe(false); + settings.FailOn.ShouldBe(Severity.Critical); + settings.TransitivePinning.ShouldBe(true); + settings.Retention!.MaxBackups.ShouldBe(3); + settings.IgnorePackages.ShouldNotBeNull().ShouldContain("Internal.*"); + } + + [Fact] + public async Task LoadAsync_ShouldWalkUpTheDirectoryTree() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, ConfigurationLoader.FileName), "{}") + .AddDirectory(Path.Combine(Root, "src", "App")); + + var (settings, _, _) = await new ConfigurationLoader(fileSystem).LoadAsync( + Path.Combine(Root, "src", "App") + ); + + settings.ShouldNotBeNull(); + } + + [Fact] + public async Task LoadAsync_ShouldReturnNothingWhenThereIsNoFile() + { + var (settings, path, error) = await new ConfigurationLoader( + new InMemoryFileSystem() + ).LoadAsync(Root); + + settings.ShouldBeNull(); + path.ShouldBeNull(); + error.ShouldBeNull(); + } + + [Fact] + public async Task LoadAsync_ShouldReportMalformedJson() + { + var fileSystem = new InMemoryFileSystem().AddFile( + Path.Combine(Root, ConfigurationLoader.FileName), + "{ not json" + ); + + var (settings, _, error) = await new ConfigurationLoader(fileSystem).LoadAsync(Root); + + settings.ShouldBeNull(); + error.ShouldNotBeNull(); + } + + [Fact] + public async Task LoadAsync_ShouldTolerateCommentsAndTrailingCommas() + { + var json = """ + { + // the default strategy + "conflictStrategy": "Highest", + } + """; + + var fileSystem = new InMemoryFileSystem().AddFile( + Path.Combine(Root, ConfigurationLoader.FileName), + json + ); + + var (settings, _, error) = await new ConfigurationLoader(fileSystem).LoadAsync(Root); + + error.ShouldBeNull(); + settings!.ConflictStrategy.ShouldBe(VersionResolutionStrategyName.Highest); + } + + [Fact] + public async Task ScaffoldAsync_ShouldWriteAConfigThatLoadsBack() + { + var fileSystem = new InMemoryFileSystem(); + var loader = new ConfigurationLoader(fileSystem); + + var path = await loader.ScaffoldAsync(Root, overwrite: false); + + fileSystem.ReadText(path).ShouldContain("$schema"); + + var (settings, _, error) = await loader.LoadAsync(Root); + error.ShouldBeNull(); + settings.ShouldNotBeNull(); + } + + [Fact] + public async Task ScaffoldAsync_ShouldNotOverwriteWithoutPermission() + { + var path = Path.Combine(Root, ConfigurationLoader.FileName); + var fileSystem = new InMemoryFileSystem().AddFile(path, "{ \"backup\": false }"); + + await new ConfigurationLoader(fileSystem).ScaffoldAsync(Root, overwrite: false); + + fileSystem.ReadText(path).ShouldBe("{ \"backup\": false }"); + } +} diff --git a/CentralConfigGenerator.Core.Tests/CentralConfigGenerator.Core.Tests.csproj b/CentralConfigGenerator.Core.Tests/CentralConfigGenerator.Core.Tests.csproj index 0ebbfec..f04bff7 100644 --- a/CentralConfigGenerator.Core.Tests/CentralConfigGenerator.Core.Tests.csproj +++ b/CentralConfigGenerator.Core.Tests/CentralConfigGenerator.Core.Tests.csproj @@ -1,5 +1,7 @@ + net9.0 + Major false true diff --git a/CentralConfigGenerator.Core.Tests/Discovery/ProjectDiscoveryServiceTests.cs b/CentralConfigGenerator.Core.Tests/Discovery/ProjectDiscoveryServiceTests.cs new file mode 100644 index 0000000..02d7778 --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/Discovery/ProjectDiscoveryServiceTests.cs @@ -0,0 +1,136 @@ +using CentralConfigGenerator.Core.Discovery; +using CentralConfigGenerator.Core.Models; +using CentralConfigGenerator.Core.Tests.TestSupport; + +namespace CentralConfigGenerator.Core.Tests.Discovery; + +public class ProjectDiscoveryServiceTests +{ + private static readonly string Root = Path.Combine(Path.GetTempPath(), "ccg-discovery"); + + [Fact] + public async Task DiscoverAsync_ShouldFindProjectsRecursively() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "src", "App", "App.csproj"), Sample.AppProject) + .AddFile(Path.Combine(Root, "src", "Lib", "Lib.fsproj"), Sample.LibProject); + + var service = new ProjectDiscoveryService(fileSystem); + + var projects = await service.DiscoverAsync(new DiscoveryOptions { RootDirectory = Root }); + + projects.Count.ShouldBe(2); + projects.Select(p => p.ResolvedKind).ShouldContain(ProjectKind.FSharp); + } + + [Fact] + public async Task DiscoverAsync_ShouldSkipBinObjAndBackupDirectories() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "App.csproj"), Sample.AppProject) + .AddFile(Path.Combine(Root, "bin", "Debug", "Ghost.csproj"), Sample.AppProject) + .AddFile(Path.Combine(Root, "obj", "Ghost.csproj"), Sample.AppProject) + .AddFile(Path.Combine(Root, "node_modules", "Ghost.csproj"), Sample.AppProject) + .AddFile( + Path.Combine(Root, ".centralconfig-backups", "20250101", "App.csproj"), + Sample.AppProject + ); + + var service = new ProjectDiscoveryService(fileSystem); + + var projects = await service.DiscoverAsync(new DiscoveryOptions { RootDirectory = Root }); + + projects.Count.ShouldBe(1); + projects[0].FileName.ShouldBe("App.csproj"); + } + + [Fact] + public async Task DiscoverAsync_ShouldHonourExcludeRegex() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "src", "App.csproj"), Sample.AppProject) + .AddFile(Path.Combine(Root, "samples", "Demo.csproj"), Sample.AppProject); + + var service = new ProjectDiscoveryService(fileSystem); + + var projects = await service.DiscoverAsync( + new DiscoveryOptions { RootDirectory = Root, ExcludePattern = "^samples$" } + ); + + projects.Count.ShouldBe(1); + projects[0].FileName.ShouldBe("App.csproj"); + } + + [Fact] + public async Task DiscoverAsync_ShouldRestrictToRequestedKinds() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "App.csproj"), Sample.AppProject) + .AddFile(Path.Combine(Root, "Lib.fsproj"), Sample.LibProject); + + var service = new ProjectDiscoveryService(fileSystem); + + var projects = await service.DiscoverAsync( + new DiscoveryOptions { RootDirectory = Root, IncludedKinds = [ProjectKind.CSharp] } + ); + + projects.Count.ShouldBe(1); + projects[0].ResolvedKind.ShouldBe(ProjectKind.CSharp); + } + + [Fact] + public async Task DiscoverAsync_ShouldFollowSolutionWhenGiven() + { + var solutionPath = Path.Combine(Root, "My.sln"); + var projectPath = Path.Combine(Root, "src", "App", "App.csproj"); + + var fileSystem = new InMemoryFileSystem() + .AddFile(projectPath, Sample.AppProject) + .AddFile(Path.Combine(Root, "unlisted", "Other.csproj"), Sample.AppProject) + .AddFile( + solutionPath, + """ + Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App", "src/App/App.csproj", "{G}" + EndProject + """ + ); + + var service = new ProjectDiscoveryService(fileSystem); + + var projects = await service.DiscoverAsync( + new DiscoveryOptions { RootDirectory = Root, SolutionPath = solutionPath } + ); + + projects.Count.ShouldBe(1); + projects[0].Path.ShouldBe(projectPath); + } + + [Fact] + public async Task DiscoverAsync_ShouldReturnEmptyForMissingDirectory() + { + var service = new ProjectDiscoveryService(new InMemoryFileSystem()); + + var projects = await service.DiscoverAsync( + new DiscoveryOptions { RootDirectory = Path.Combine(Root, "nope") } + ); + + projects.ShouldBeEmpty(); + } + + [Fact] + public async Task SaveAsync_ShouldPreserveTheOriginalFormat() + { + var path = Path.Combine(Root, "App.csproj"); + var fileSystem = new InMemoryFileSystem(); + fileSystem.AddFile(path, System.Text.Encoding.UTF8.GetBytes("\r\n\r\n")); + + var service = new ProjectDiscoveryService(fileSystem); + var project = await service.LoadAsync(path); + + project.ShouldNotBeNull(); + await service.SaveAsync(project); + + System.Text.Encoding.UTF8.GetString(fileSystem.ReadBytes(path)) + .ShouldBe("\r\n\r\n"); + } +} diff --git a/CentralConfigGenerator.Core.Tests/Discovery/SolutionReaderTests.cs b/CentralConfigGenerator.Core.Tests/Discovery/SolutionReaderTests.cs new file mode 100644 index 0000000..9517c58 --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/Discovery/SolutionReaderTests.cs @@ -0,0 +1,103 @@ +using CentralConfigGenerator.Core.Discovery; + +namespace CentralConfigGenerator.Core.Tests.Discovery; + +public class SolutionReaderTests +{ + [Theory] + [InlineData("MySolution.sln", true)] + [InlineData("MySolution.slnx", true)] + [InlineData("MySolution.slnf", true)] + [InlineData("MyProject.csproj", false)] + public void IsSolution_ShouldRecogniseSolutionExtensions(string path, bool expected) => + SolutionReader.IsSolution(path).ShouldBe(expected); + + [Fact] + public void ReadProjects_ShouldParseClassicSlnFormat() + { + var content = """ + Microsoft Visual Studio Solution File, Format Version 12.00 + Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App", "src\App\App.csproj", "{GUID1}" + EndProject + Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{FOLDER}" + EndProject + Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Lib", "src\Lib\Lib.fsproj", "{GUID2}" + EndProject + """; + + var projects = SolutionReader.ReadProjects( + Path.Combine(Path.GetTempPath(), "MySolution.sln"), + content + ); + + projects.Count.ShouldBe(2); + projects.ShouldContain(p => p.EndsWith("App.csproj", StringComparison.Ordinal)); + projects.ShouldContain(p => p.EndsWith("Lib.fsproj", StringComparison.Ordinal)); + } + + [Fact] + public void ReadProjects_ShouldSkipSolutionFolders() + { + var content = """ + Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{FOLDER}" + EndProject + """; + + SolutionReader + .ReadProjects(Path.Combine(Path.GetTempPath(), "S.sln"), content) + .ShouldBeEmpty(); + } + + [Fact] + public void ReadProjects_ShouldParseSlnxFormat() + { + var content = """ + + + + + + + """; + + var projects = SolutionReader.ReadProjects( + Path.Combine(Path.GetTempPath(), "MySolution.slnx"), + content + ); + + projects.Count.ShouldBe(2); + } + + [Fact] + public void ReadProjects_ShouldParseSolutionFilter() + { + var content = """ + { + "solution": { + "path": "MySolution.sln", + "projects": [ "src/App/App.csproj" ] + } + } + """; + + var projects = SolutionReader.ReadProjects( + Path.Combine(Path.GetTempPath(), "MySolution.slnf"), + content + ); + + projects.Count.ShouldBe(1); + projects[0].ShouldEndWith("App.csproj"); + } + + [Fact] + public void ReadProjects_ShouldReturnEmptyForMalformedXml() => + SolutionReader + .ReadProjects(Path.Combine(Path.GetTempPath(), "Broken.slnx"), "") + .ShouldBeEmpty(); + + [Fact] + public void ReadProjects_ShouldReturnEmptyForMalformedJson() => + SolutionReader + .ReadProjects(Path.Combine(Path.GetTempPath(), "Broken.slnf"), "{ not json") + .ShouldBeEmpty(); +} diff --git a/CentralConfigGenerator.Core.Tests/IO/EncodingDetectorTests.cs b/CentralConfigGenerator.Core.Tests/IO/EncodingDetectorTests.cs new file mode 100644 index 0000000..bc978b5 --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/IO/EncodingDetectorTests.cs @@ -0,0 +1,95 @@ +using System.Text; +using CentralConfigGenerator.Core.IO; +using CentralConfigGenerator.Core.Models; + +namespace CentralConfigGenerator.Core.Tests.IO; + +public class EncodingDetectorTests +{ + [Fact] + public void Decode_ShouldDetectUtf8WithoutBom() + { + var bytes = new UTF8Encoding(false).GetBytes(""); + + var (content, format) = EncodingDetector.Decode(bytes); + + content.ShouldBe(""); + format.HasByteOrderMark.ShouldBeFalse(); + } + + [Fact] + public void Decode_ShouldDetectUtf8Bom() + { + var bytes = new UTF8Encoding(true).GetPreamble() + .Concat(new UTF8Encoding(false).GetBytes("")) + .ToArray(); + + var (content, format) = EncodingDetector.Decode(bytes); + + content.ShouldBe(""); + format.HasByteOrderMark.ShouldBeTrue(); + } + + [Fact] + public void Decode_ShouldDetectUtf16LittleEndian() + { + var bytes = new UnicodeEncoding(false, true).GetPreamble() + .Concat(new UnicodeEncoding(false, false).GetBytes("")) + .ToArray(); + + var (content, format) = EncodingDetector.Decode(bytes); + + content.ShouldBe(""); + format.HasByteOrderMark.ShouldBeTrue(); + format.Encoding.ShouldBeOfType(); + } + + [Theory] + [InlineData("a\r\nb\r\nc", LineEndingStyle.CrLf)] + [InlineData("a\nb\nc", LineEndingStyle.Lf)] + [InlineData("a\rb\rc", LineEndingStyle.Cr)] + [InlineData("single line", LineEndingStyle.Preserve)] + public void Decode_ShouldDetectLineEndings(string content, LineEndingStyle expected) + { + var (_, format) = EncodingDetector.Decode(Encoding.UTF8.GetBytes(content)); + + format.LineEnding.ShouldBe(expected); + } + + [Fact] + public void RoundTrip_ShouldPreserveBytesExactly() + { + var original = new UTF8Encoding(true).GetPreamble() + .Concat(new UTF8Encoding(false).GetBytes("\r\n \r\n\r\n")) + .ToArray(); + + var (content, format) = EncodingDetector.Decode(original); + var round = EncodingDetector.Encode(content, format); + + round.ShouldBe(original); + } + + [Fact] + public void Encode_ShouldRewriteLineEndingsWhenAsked() + { + var (content, format) = EncodingDetector.Decode(Encoding.UTF8.GetBytes("a\r\nb\r\n")); + + var bytes = EncodingDetector.Encode(content, format with { LineEnding = LineEndingStyle.Lf }); + + Encoding.UTF8.GetString(bytes).ShouldBe("a\nb\n"); + } + + [Theory] + [InlineData("utf-8")] + [InlineData("UTF-8")] + [InlineData("us-ascii")] + public void Resolve_ShouldAcceptIanaNames(string name) => + EncodingDetector.Resolve(name).ShouldNotBeNull(); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("not-a-real-encoding")] + public void Resolve_ShouldReturnNullForUnknownNames(string? name) => + EncodingDetector.Resolve(name).ShouldBeNull(); +} diff --git a/CentralConfigGenerator.Core.Tests/Migration/MigrationServiceTests.cs b/CentralConfigGenerator.Core.Tests/Migration/MigrationServiceTests.cs new file mode 100644 index 0000000..6e491c8 --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/Migration/MigrationServiceTests.cs @@ -0,0 +1,252 @@ +using CentralConfigGenerator.Core.Discovery; +using CentralConfigGenerator.Core.Migration; +using CentralConfigGenerator.Core.Models; +using CentralConfigGenerator.Core.Services; +using CentralConfigGenerator.Core.Tests.TestSupport; + +namespace CentralConfigGenerator.Core.Tests.Migration; + +public class MigrationServiceTests +{ + private static readonly string Root = Path.Combine(Path.GetTempPath(), "ccg-migration"); + + [Fact] + public async Task PlanAsync_ShouldCreateThePropsFileAndStripEveryProject() + { + var (service, _) = Create(Workspace()); + + var plan = await service.PlanAsync(Options()); + + plan.ProjectCount.ShouldBe(2); + plan.Changes.ShouldContain(c => c.Kind == FileChangeKind.Created); + plan.Changes.Count(c => c.Kind == FileChangeKind.Modified).ShouldBe(2); + plan.ResolvedVersions["Newtonsoft.Json"].ShouldBe("13.0.3"); + } + + [Fact] + public async Task PlanAsync_ShouldNotWriteAnythingToDisk() + { + var fileSystem = Workspace(); + var (service, _) = Create(fileSystem); + + await service.PlanAsync(Options()); + + fileSystem.FileExists(Path.Combine(Root, "Directory.Packages.props")).ShouldBeFalse(); + } + + [Fact] + public async Task PlanAsync_ShouldRecordConflicts() + { + var (service, _) = Create(Workspace()); + + var plan = await service.PlanAsync(Options()); + + plan.Conflicts.ShouldContain(c => c.PackageId == "Newtonsoft.Json"); + plan.Conflicts.First(c => c.PackageId == "Newtonsoft.Json").ResolvedVersion.ShouldBe("13.0.3"); + } + + [Fact] + public async Task PlanAsync_ShouldRespectTheLowestStrategy() + { + var (service, _) = Create(Workspace()); + + var plan = await service.PlanAsync( + Options() with { ConflictStrategy = VersionResolutionStrategy.Lowest } + ); + + plan.ResolvedVersions["Newtonsoft.Json"].ShouldBe("13.0.1"); + } + + [Fact] + public async Task PlanAsync_ShouldThrowForTheFailStrategy() + { + var (service, _) = Create(Workspace()); + + await Should.ThrowAsync(() => + service.PlanAsync( + Options() with + { + ConflictStrategy = VersionResolutionStrategy.Fail, + FailOnConflict = true, + } + ) + ); + } + + [Fact] + public async Task PlanAsync_ShouldUseTheCallerSuppliedResolution() + { + var (service, _) = Create(Workspace()); + + var plan = await service.PlanAsync(Options(), _ => "99.0.0"); + + plan.ResolvedVersions["Newtonsoft.Json"].ShouldBe("99.0.0"); + } + + [Fact] + public async Task PlanAsync_ShouldKeepInlineVersionsWhenAsked() + { + var (service, _) = Create(Workspace()); + + var plan = await service.PlanAsync(Options() with { KeepInlineVersions = true }); + + plan.Changes.Count.ShouldBe(1); + plan.Changes[0].Kind.ShouldBe(FileChangeKind.Created); + } + + [Fact] + public async Task PlanAsync_ShouldMergeIntoAnExistingPropsFile() + { + var fileSystem = Workspace() + .AddFile(Path.Combine(Root, "Directory.Packages.props"), Sample.PackagesProps); + + var (service, _) = Create(fileSystem); + + var plan = await service.PlanAsync(Options() with { Merge = true }); + + var change = plan.Changes.First(c => c.Path.EndsWith("Directory.Packages.props", StringComparison.Ordinal)); + change.Kind.ShouldBe(FileChangeKind.Modified); + change.NewContent.ShouldContain("Newtonsoft.Json"); + } + + [Fact] + public async Task PlanAsync_ShouldAlsoHoistPropertiesWhenUnifyingIsOn() + { + var (service, _) = Create(Workspace()); + + var plan = await service.PlanAsync(Options() with { UnifyProperties = true }); + + plan.Changes.ShouldContain(c => c.Path.EndsWith("Directory.Build.props", StringComparison.Ordinal)); + } + + [Fact] + public async Task PlanAsync_ShouldReturnAnEmptyPlanWhenNoProjectsExist() + { + var (service, _) = Create(new InMemoryFileSystem().AddDirectory(Root)); + + var plan = await service.PlanAsync(Options()); + + plan.ProjectCount.ShouldBe(0); + plan.HasChanges.ShouldBeFalse(); + } + + [Fact] + public async Task ApplyAsync_ShouldWriteEveryChangeAndTakeABackup() + { + var fileSystem = Workspace(); + var (service, _) = Create(fileSystem); + + var plan = await service.PlanAsync(Options()); + var backup = await service.ApplyAsync(plan, Options()); + + fileSystem.FileExists(Path.Combine(Root, "Directory.Packages.props")).ShouldBeTrue(); + fileSystem.ReadText(Path.Combine(Root, "a", "A.csproj")).ShouldNotContain("Version="); + backup.ShouldNotBeNull(); + backup.Files.Count.ShouldBe(3); + } + + [Fact] + public async Task ApplyAsync_ShouldSkipTheBackupWhenDisabled() + { + var (service, _) = Create(Workspace()); + + var plan = await service.PlanAsync(Options()); + var backup = await service.ApplyAsync(plan, Options() with { CreateBackup = false }); + + backup.ShouldBeNull(); + } + + [Fact] + public async Task ApplyAsync_ShouldRewriteLineEndingsWhenRequested() + { + var fileSystem = new InMemoryFileSystem().AddFile( + Path.Combine(Root, "a", "A.csproj"), + System.Text.Encoding.UTF8.GetBytes( + "\r\n \r\n \r\n \r\n\r\n" + ) + ); + + var (service, _) = Create(fileSystem); + var options = Options() with { LineEnding = LineEndingStyle.Lf, CreateBackup = false }; + + var plan = await service.PlanAsync(options); + await service.ApplyAsync(plan, options); + + var written = System.Text.Encoding.UTF8.GetString( + fileSystem.ReadBytes(Path.Combine(Root, "a", "A.csproj")) + ); + + written.ShouldNotContain("\r\n"); + } + + [Fact] + public async Task PlanRevertAsync_ShouldInlineVersionsAndDeleteThePropsFile() + { + var fileSystem = new InMemoryFileSystem() + .AddFile( + Path.Combine(Root, "a", "A.csproj"), + """ + + + + + + """ + ) + .AddFile(Path.Combine(Root, "Directory.Packages.props"), Sample.PackagesProps); + + var (service, _) = Create(fileSystem); + + var plan = await service.PlanRevertAsync(Options()); + + plan.Changes.ShouldContain(c => c.Kind == FileChangeKind.Deleted); + plan.Changes.First(c => c.Kind == FileChangeKind.Modified).NewContent + .ShouldContain("Version=\"3.1.1\""); + } + + [Fact] + public async Task PlanRevertAsync_ShouldReportPackagesWithNoCentralVersion() + { + var fileSystem = new InMemoryFileSystem() + .AddFile( + Path.Combine(Root, "a", "A.csproj"), + """ + + + + + + """ + ) + .AddFile(Path.Combine(Root, "Directory.Packages.props"), Sample.PackagesProps); + + var (service, _) = Create(fileSystem); + + var plan = await service.PlanRevertAsync(Options()); + + plan.Findings.ShouldContain(f => f.RuleId == Core.Analysis.RuleIds.MissingCentralVersion); + } + + private static MigrationOptions Options() => + new() { RootDirectory = Root, BackupDirectory = Root }; + + private static InMemoryFileSystem Workspace() => + new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "a", "A.csproj"), Sample.AppProject) + .AddFile(Path.Combine(Root, "b", "B.csproj"), Sample.LibProject); + + private static (MigrationService Service, InMemoryFileSystem FileSystem) Create( + InMemoryFileSystem fileSystem + ) + { + var discovery = new ProjectDiscoveryService(fileSystem); + var service = new MigrationService( + discovery, + fileSystem, + new BackupService(fileSystem), + new VersionSelector(new VersionConflictResolver()) + ); + + return (service, fileSystem); + } +} diff --git a/CentralConfigGenerator.Core.Tests/Migration/PackagesPropsEditorTests.cs b/CentralConfigGenerator.Core.Tests/Migration/PackagesPropsEditorTests.cs new file mode 100644 index 0000000..c739291 --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/Migration/PackagesPropsEditorTests.cs @@ -0,0 +1,74 @@ +using CentralConfigGenerator.Core.Generators; +using CentralConfigGenerator.Core.Tests.TestSupport; + +namespace CentralConfigGenerator.Core.Tests.Migration; + +public class PackagesPropsEditorTests +{ + [Fact] + public void SetVersion_ShouldUpdateAnExistingEntry() + { + var updated = PackagesPropsEditor.SetVersion(Sample.PackagesProps, "Serilog", "4.0.0"); + + updated.ShouldNotBeNull(); + updated.ShouldContain("Include=\"Serilog\" Version=\"4.0.0\""); + } + + [Fact] + public void SetVersion_ShouldReturnNullWhenNothingChanges() => + PackagesPropsEditor.SetVersion(Sample.PackagesProps, "Serilog", "3.1.1").ShouldBeNull(); + + [Fact] + public void SetVersion_ShouldAppendANewEntry() + { + var updated = PackagesPropsEditor.SetVersion(Sample.PackagesProps, "Polly", "8.4.1"); + + updated.ShouldNotBeNull(); + updated.ShouldContain(""); + updated.ShouldContain("Serilog"); + } + + [Fact] + public void RemoveEntry_ShouldDeleteThePackageVersion() + { + var updated = PackagesPropsEditor.RemoveEntry(Sample.PackagesProps, "Serilog"); + + updated.ShouldNotBeNull(); + updated.ShouldNotContain("Serilog"); + updated.ShouldContain("Newtonsoft.Json"); + } + + [Fact] + public void RemoveEntry_ShouldReturnNullForAnUnknownPackage() => + PackagesPropsEditor.RemoveEntry(Sample.PackagesProps, "Nope").ShouldBeNull(); + + [Fact] + public void NormalizeCasing_ShouldRewriteTheIncludeAttribute() + { + var content = Sample.PackagesProps.Replace("Newtonsoft.Json", "newtonsoft.json"); + + var updated = PackagesPropsEditor.NormalizeCasing(content, "Newtonsoft.Json"); + + updated.ShouldNotBeNull(); + updated.ShouldContain("Include=\"Newtonsoft.Json\""); + } + + [Fact] + public void DeduplicateEntries_ShouldKeepOnlyTheFirstSpelling() + { + var content = """ + + + + + + + """; + + var updated = PackagesPropsEditor.DeduplicateEntries(content); + + updated.ShouldNotBeNull(); + updated.ShouldContain("Version=\"3.1.1\""); + updated.ShouldNotContain("2.0.0"); + } +} diff --git a/CentralConfigGenerator.Core.Tests/Migration/PackagesPropsWriterTests.cs b/CentralConfigGenerator.Core.Tests/Migration/PackagesPropsWriterTests.cs new file mode 100644 index 0000000..fa4a0ec --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/Migration/PackagesPropsWriterTests.cs @@ -0,0 +1,123 @@ +using CentralConfigGenerator.Core.Generators; + +namespace CentralConfigGenerator.Core.Tests.Migration; + +public class PackagesPropsWriterTests +{ + private static readonly Dictionary Versions = new(StringComparer.OrdinalIgnoreCase) + { + ["Serilog"] = "3.1.1", + ["Newtonsoft.Json"] = "13.0.3", + }; + + [Fact] + public void Create_ShouldEnableCentralManagementAndSortEntries() + { + var content = PackagesPropsWriter.Create(Versions); + + content.ShouldContain("true"); + content.IndexOf("Newtonsoft.Json", StringComparison.Ordinal) + .ShouldBeLessThan(content.IndexOf("Serilog", StringComparison.Ordinal)); + } + + [Fact] + public void Create_ShouldEmitTransitivePinningWhenRequested() + { + var content = PackagesPropsWriter.Create( + Versions, + new PackagesPropsOptions { TransitivePinning = true } + ); + + content.ShouldContain("true"); + } + + [Fact] + public void Create_ShouldEmitGlobalPackageReferencesSeparately() + { + var content = PackagesPropsWriter.Create( + Versions, + new PackagesPropsOptions { GlobalPackages = ["Serilog"] } + ); + + content.ShouldContain(""); + content.ShouldNotContain(" { ["A&B"] = "1.0.0" } + ); + + content.ShouldContain("A&B"); + } + + [Fact] + public void Merge_ShouldAddMissingEntriesAndKeepComments() + { + var existing = """ + + + true + + + + + + + """; + + var merged = PackagesPropsWriter.Merge(existing, Versions); + + merged.ShouldContain(""); + merged.ShouldContain("Include=\"Serilog\" Version=\"3.1.1\""); + merged.ShouldContain("Newtonsoft.Json"); + } + + [Fact] + public void Merge_ShouldAddTransitivePinningToAnExistingPropertyGroup() + { + var existing = """ + + + true + + + """; + + var merged = PackagesPropsWriter.Merge( + existing, + Versions, + new PackagesPropsOptions { TransitivePinning = true } + ); + + merged.ShouldContain("CentralPackageTransitivePinningEnabled"); + } + + [Fact] + public void Merge_ShouldFallBackToCreateForUnparseableInput() + { + var merged = PackagesPropsWriter.Merge("", Versions); + + merged.ShouldContain("true"); + } + + [Fact] + public void Merge_ShouldUpdateChildElementVersions() + { + var existing = """ + + + + 1.0.0 + + + + """; + + var merged = PackagesPropsWriter.Merge(existing, Versions); + + merged.ShouldContain("3.1.1"); + } +} diff --git a/CentralConfigGenerator.Core.Tests/Migration/ProjectRewriterTests.cs b/CentralConfigGenerator.Core.Tests/Migration/ProjectRewriterTests.cs new file mode 100644 index 0000000..bc85450 --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/Migration/ProjectRewriterTests.cs @@ -0,0 +1,185 @@ +using CentralConfigGenerator.Core.Migration; +using CentralConfigGenerator.Core.Models; +using CentralConfigGenerator.Core.Tests.TestSupport; + +namespace CentralConfigGenerator.Core.Tests.Migration; + +public class ProjectRewriterTests +{ + [Fact] + public void StripVersions_ShouldRemoveAttributeVersions() + { + var updated = ProjectRewriter.StripVersions(Project(Sample.AppProject), out var touched); + + updated.ShouldNotBeNull(); + updated.ShouldNotContain("Version="); + updated.ShouldContain(""); + touched.Count.ShouldBe(2); + } + + [Fact] + public void StripVersions_ShouldCollapseChildElementVersions() + { + var updated = ProjectRewriter.StripVersions(Project(Sample.LibProject), out _); + + updated.ShouldNotBeNull(); + updated.ShouldNotContain(""); + updated.ShouldContain(""); + } + + [Fact] + public void StripVersions_ShouldPreserveEverythingElse() + { + var updated = ProjectRewriter.StripVersions(Project(Sample.AppProject), out _); + + updated.ShouldNotBeNull(); + updated.ShouldContain("Exe"); + updated.ShouldContain("net8.0"); + } + + [Fact] + public void StripVersions_ShouldReturnNullWhenThereIsNothingToDo() + { + var content = """ + + + + + + """; + + ProjectRewriter.StripVersions(Project(content), out _).ShouldBeNull(); + } + + [Fact] + public void StripVersion_ShouldOnlyTouchTheNamedPackage() + { + var updated = ProjectRewriter.StripVersion(Project(Sample.AppProject), "Serilog"); + + updated.ShouldNotBeNull(); + updated.ShouldContain("Include=\"Newtonsoft.Json\" Version=\"13.0.1\""); + updated.ShouldContain(""); + } + + [Fact] + public void RestoreVersions_ShouldInlineCentralVersionsAfterInclude() + { + var content = """ + + + + + + """; + + var updated = ProjectRewriter.RestoreVersions( + Project(content), + new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Serilog"] = "3.1.1" }, + out var unresolved + ); + + updated.ShouldNotBeNull(); + updated.ShouldContain("Include=\"Serilog\" Version=\"3.1.1\" PrivateAssets=\"all\""); + unresolved.ShouldBeEmpty(); + } + + [Fact] + public void RestoreVersions_ShouldReportPackagesWithNoCentralEntry() + { + var content = """ + + + + + + """; + + ProjectRewriter.RestoreVersions( + Project(content), + new Dictionary(), + out var unresolved + ); + + unresolved.ShouldBe(["Unknown"]); + } + + [Fact] + public void RemoveProperties_ShouldDeleteMatchingValuesOnly() + { + var updated = ProjectRewriter.RemoveProperties( + Project(Sample.AppProject), + ["Nullable", "TargetFramework"], + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Nullable"] = "enable", + ["TargetFramework"] = "net9.0", + } + ); + + updated.ShouldNotBeNull(); + updated.ShouldNotContain(""); + // net8.0 does not match the expected net9.0, so it must stay. + updated.ShouldContain("net8.0"); + } + + [Fact] + public void RemoveProperties_ShouldLeaveConditionedGroupsAlone() + { + var content = """ + + + enable + + + """; + + ProjectRewriter.RemoveProperties(Project(content), ["Nullable"]).ShouldBeNull(); + } + + [Fact] + public void RemoveDuplicateReference_ShouldKeepTheFirstOccurrence() + { + var content = """ + + + + + + + """; + + var updated = ProjectRewriter.RemoveDuplicateReference(Project(content), "xunit"); + + updated.ShouldNotBeNull(); + updated.Split(" + ProjectRewriter.RemoveDuplicateReference(Project(Sample.AppProject), "Serilog").ShouldBeNull(); + + [Fact] + public void NormalizeCasing_ShouldRewriteToCanonicalSpelling() + { + var content = """ + + + + + + """; + + var updated = ProjectRewriter.NormalizeCasing(Project(content), "Newtonsoft.Json"); + + updated.ShouldNotBeNull(); + updated.ShouldContain("Include=\"Newtonsoft.Json\""); + } + + [Fact] + public void NormalizeCasing_ShouldReturnNullWhenAlreadyCanonical() => + ProjectRewriter.NormalizeCasing(Project(Sample.AppProject), "Newtonsoft.Json").ShouldBeNull(); + + private static ProjectFile Project(string content) => + new() { Path = Path.Combine(Path.GetTempPath(), "Test.csproj"), Content = content }; +} diff --git a/CentralConfigGenerator.Core.Tests/Migration/PropertyHoisterTests.cs b/CentralConfigGenerator.Core.Tests/Migration/PropertyHoisterTests.cs new file mode 100644 index 0000000..29f27cc --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/Migration/PropertyHoisterTests.cs @@ -0,0 +1,86 @@ +using CentralConfigGenerator.Core.Migration; +using CentralConfigGenerator.Core.Models; + +namespace CentralConfigGenerator.Core.Tests.Migration; + +public class PropertyHoisterTests +{ + [Fact] + public void FindHoistableProperties_ShouldReturnUnanimousValues() + { + var projects = new[] + { + Project("enable"), + Project("enable"), + }; + + var result = PropertyHoister.FindHoistableProperties( + projects, + BuildPropertyDefaults.Recommended + ); + + result["Nullable"].ShouldBe("enable"); + } + + [Fact] + public void FindHoistableProperties_ShouldSkipPropertiesThatDisagree() + { + var projects = new[] + { + Project("net8.0"), + Project("net9.0"), + }; + + PropertyHoister + .FindHoistableProperties(projects, BuildPropertyDefaults.Recommended) + .ShouldNotContainKey("TargetFramework"); + } + + [Fact] + public void FindHoistableProperties_ShouldRequireAtLeastTwoProjects() + { + var projects = new[] + { + Project("enable"), + }; + + PropertyHoister + .FindHoistableProperties(projects, BuildPropertyDefaults.Recommended) + .ShouldBeEmpty(); + } + + [Fact] + public void FindHoistableProperties_ShouldNeverHoistIdentityProperties() + { + var projects = new[] + { + Project("Same"), + Project("Same"), + }; + + PropertyHoister + .FindHoistableProperties(projects, ["AssemblyName"]) + .ShouldNotContainKey("AssemblyName"); + } + + [Fact] + public void FindHoistableProperties_ShouldIgnoreConditionedGroups() + { + var projects = new[] + { + Project("enable"), + Project("enable"), + }; + + PropertyHoister + .FindHoistableProperties(projects, BuildPropertyDefaults.Recommended) + .ShouldBeEmpty(); + } + + private static ProjectFile Project(string body) => + new() + { + Path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".csproj"), + Content = $"{body}", + }; +} diff --git a/CentralConfigGenerator.Core.Tests/Reporting/ReportWriterTests.cs b/CentralConfigGenerator.Core.Tests/Reporting/ReportWriterTests.cs new file mode 100644 index 0000000..a908257 --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/Reporting/ReportWriterTests.cs @@ -0,0 +1,146 @@ +using System.Text.Json; +using CentralConfigGenerator.Core.Analysis; +using CentralConfigGenerator.Core.Reporting; + +namespace CentralConfigGenerator.Core.Tests.Reporting; + +public class ReportWriterTests +{ + private static readonly AnalysisReport Report = new() + { + ProjectCount = 2, + PackageCount = 3, + RootDirectory = Path.Combine(Path.GetTempPath(), "ccg-report"), + Duration = TimeSpan.FromSeconds(1.5), + Findings = + [ + new Finding + { + RuleId = RuleIds.SecurityVulnerability, + Severity = Severity.Critical, + Message = "'Pkg' 1.0.0 has a known advisory.", + PackageId = "Pkg", + Version = "1.0.0", + ProjectPath = Path.Combine(Path.GetTempPath(), "ccg-report", "src", "A.csproj"), + Recommendation = "Upgrade to 1.0.1.", + Fix = FixKind.SetCentralVersion, + FixValue = "1.0.1", + }, + new Finding + { + RuleId = RuleIds.FloatingVersion, + Severity = Severity.Moderate, + Message = "'xunit' uses a floating version.", + PackageId = "xunit", + }, + ], + }; + + [Fact] + public void Json_ShouldProduceParseableOutputWithASummary() + { + var json = new JsonReportWriter().Write(Report); + + using var document = JsonDocument.Parse(json); + var summary = document.RootElement.GetProperty("summary"); + + summary.GetProperty("projectCount").GetInt32().ShouldBe(2); + summary.GetProperty("bySeverity").GetProperty("critical").GetInt32().ShouldBe(1); + document.RootElement.GetProperty("findings").GetArrayLength().ShouldBe(2); + } + + [Fact] + public void Json_ShouldNotEscapeApostrophes() => + new JsonReportWriter().Write(Report).ShouldContain("'Pkg'"); + + [Fact] + public void Json_ShouldEmitWorkspaceRelativeProjectPaths() + { + var json = new JsonReportWriter().Write(Report); + + json.ShouldContain("src/A.csproj"); + } + + [Fact] + public void Sarif_ShouldEmitValidSchemaAndRules() + { + var sarif = new SarifReportWriter().Write(Report); + + using var document = JsonDocument.Parse(sarif); + document.RootElement.GetProperty("version").GetString().ShouldBe("2.1.0"); + document.RootElement.TryGetProperty("$schema", out _).ShouldBeTrue(); + + var run = document.RootElement.GetProperty("runs")[0]; + run.GetProperty("results").GetArrayLength().ShouldBe(2); + run.GetProperty("tool").GetProperty("driver").GetProperty("rules").GetArrayLength().ShouldBe(2); + } + + [Fact] + public void Sarif_ShouldMapCriticalToError() + { + var sarif = new SarifReportWriter().Write(Report); + + using var document = JsonDocument.Parse(sarif); + document + .RootElement.GetProperty("runs")[0] + .GetProperty("results")[0] + .GetProperty("level") + .GetString() + .ShouldBe("error"); + } + + [Fact] + public void Markdown_ShouldLeadWithTheVerdict() + { + var markdown = new MarkdownReportWriter().Write(Report); + + markdown.ShouldStartWith("## Dependency health:"); + markdown.ShouldContain("Critical issues found"); + markdown.ShouldContain(" + ReportWriterFactory.Create(format).Format.ShouldBe(format); +} diff --git a/CentralConfigGenerator.Core.Tests/Services/BackupServiceTests.cs b/CentralConfigGenerator.Core.Tests/Services/BackupServiceTests.cs new file mode 100644 index 0000000..d6e41ad --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/Services/BackupServiceTests.cs @@ -0,0 +1,156 @@ +using CentralConfigGenerator.Core.Services; +using CentralConfigGenerator.Core.Tests.TestSupport; + +namespace CentralConfigGenerator.Core.Tests.Services; + +public class BackupServiceTests +{ + private static readonly string Root = Path.Combine(Path.GetTempPath(), "ccg-backup"); + + [Fact] + public async Task CreateAsync_ShouldCopyEveryRequestedFile() + { + var path = Path.Combine(Root, "App.csproj"); + var fileSystem = new InMemoryFileSystem().AddFile(path, ""); + var service = new BackupService(fileSystem); + + var backup = await service.CreateAsync(Root, Root, "migrate", [path]); + + backup.ShouldNotBeNull(); + backup.Files.Count.ShouldBe(1); + backup.Operation.ShouldBe("migrate"); + } + + [Fact] + public async Task CreateAsync_ShouldReturnNullWhenNoFilesAreGiven() + { + var service = new BackupService(new InMemoryFileSystem()); + + (await service.CreateAsync(Root, Root, "migrate", [])).ShouldBeNull(); + } + + [Fact] + public async Task RestoreAsync_ShouldPutTheOriginalContentBack() + { + var path = Path.Combine(Root, "App.csproj"); + var fileSystem = new InMemoryFileSystem().AddFile(path, "original"); + var service = new BackupService(fileSystem); + + var backup = await service.CreateAsync(Root, Root, "migrate", [path]); + await fileSystem.WriteAllTextAsync(path, "modified"); + + await service.RestoreAsync(Root, backup!.Id); + + fileSystem.ReadText(path).ShouldBe("original"); + } + + [Fact] + public async Task RestoreAsync_ShouldDeleteFilesThatTheOperationCreated() + { + var path = Path.Combine(Root, "Directory.Packages.props"); + var fileSystem = new InMemoryFileSystem().AddDirectory(Root); + var service = new BackupService(fileSystem); + + // The file does not exist yet, so the backup records it as "created". + var backup = await service.CreateAsync(Root, Root, "migrate", [path]); + await fileSystem.WriteAllTextAsync(path, ""); + + await service.RestoreAsync(Root, backup!.Id); + + fileSystem.FileExists(path).ShouldBeFalse(); + } + + [Fact] + public async Task RestoreLatestAsync_ShouldPickTheNewestBackup() + { + var path = Path.Combine(Root, "App.csproj"); + var fileSystem = new InMemoryFileSystem().AddFile(path, "v1"); + var service = new BackupService(fileSystem); + + await service.CreateAsync(Root, Root, "first", [path]); + await fileSystem.WriteAllTextAsync(path, "v2"); + await Task.Delay(5); + await service.CreateAsync(Root, Root, "second", [path]); + await fileSystem.WriteAllTextAsync(path, "v3"); + + var restored = await service.RestoreLatestAsync(Root); + + restored!.Operation.ShouldBe("second"); + fileSystem.ReadText(path).ShouldBe("v2"); + } + + [Fact] + public async Task List_ShouldReturnBackupsNewestFirst() + { + var path = Path.Combine(Root, "App.csproj"); + var fileSystem = new InMemoryFileSystem().AddFile(path, "x"); + var service = new BackupService(fileSystem); + + await service.CreateAsync(Root, Root, "first", [path]); + await Task.Delay(5); + await service.CreateAsync(Root, Root, "second", [path]); + + var backups = service.List(Root); + + backups.Count.ShouldBe(2); + backups[0].Operation.ShouldBe("second"); + } + + [Fact] + public async Task Prune_ShouldKeepOnlyTheRetainedBackups() + { + var path = Path.Combine(Root, "App.csproj"); + var fileSystem = new InMemoryFileSystem().AddFile(path, "x"); + var service = new BackupService(fileSystem); + + for (var i = 0; i < 4; i++) + { + await service.CreateAsync(Root, Root, $"op{i}", [path]); + await Task.Delay(5); + } + + var removed = service.Prune(Root, retention: 2); + + removed.Count.ShouldBe(2); + service.List(Root).Count.ShouldBe(2); + } + + [Fact] + public async Task PruneAll_ShouldRemoveEverything() + { + var path = Path.Combine(Root, "App.csproj"); + var fileSystem = new InMemoryFileSystem().AddFile(path, "x"); + var service = new BackupService(fileSystem); + + await service.CreateAsync(Root, Root, "op", [path]); + + service.PruneAll(Root); + + service.List(Root).ShouldBeEmpty(); + } + + [Fact] + public async Task EnsureGitIgnoreAsync_ShouldAppendTheBackupFolder() + { + var fileSystem = new InMemoryFileSystem().AddFile(Path.Combine(Root, ".gitignore"), "bin/\n"); + var service = new BackupService(fileSystem); + + await service.EnsureGitIgnoreAsync(Root, BackupService.DefaultBackupFolderName); + + var content = fileSystem.ReadText(Path.Combine(Root, ".gitignore")); + content.ShouldContain("bin/"); + content.ShouldContain(".centralconfig-backups/"); + } + + [Fact] + public async Task EnsureGitIgnoreAsync_ShouldNotDuplicateAnExistingEntry() + { + var path = Path.Combine(Root, ".gitignore"); + var fileSystem = new InMemoryFileSystem().AddFile(path, ".centralconfig-backups/\n"); + var service = new BackupService(fileSystem); + + await service.EnsureGitIgnoreAsync(Root, BackupService.DefaultBackupFolderName); + + fileSystem.ReadText(path).Split(".centralconfig-backups/").Length.ShouldBe(2); + } +} diff --git a/CentralConfigGenerator.Core.Tests/Services/UnifiedDiffTests.cs b/CentralConfigGenerator.Core.Tests/Services/UnifiedDiffTests.cs new file mode 100644 index 0000000..5337c3b --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/Services/UnifiedDiffTests.cs @@ -0,0 +1,42 @@ +using CentralConfigGenerator.Core.Services; + +namespace CentralConfigGenerator.Core.Tests.Services; + +public class UnifiedDiffTests +{ + [Fact] + public void Create_ShouldReturnEmptyForIdenticalInput() => + UnifiedDiff.Create("same\ntext", "same\ntext", "a", "b").ShouldBeEmpty(); + + [Fact] + public void Create_ShouldMarkAddedAndRemovedLines() + { + var diff = UnifiedDiff.Create("one\ntwo\nthree", "one\nTWO\nthree", "a.txt", "b.txt"); + + diff.ShouldContain("--- a.txt"); + diff.ShouldContain("+++ b.txt"); + diff.ShouldContain("-two"); + diff.ShouldContain("+TWO"); + diff.ShouldContain(" one"); + } + + [Fact] + public void Create_ShouldIncludeAHunkHeader() => + UnifiedDiff.Create("a", "b", "x", "y").ShouldContain("@@"); + + [Fact] + public void Create_ShouldHandlePureInsertion() + { + var diff = UnifiedDiff.Create(string.Empty, "new line", "/dev/null", "b.txt"); + + diff.ShouldContain("+new line"); + } + + [Fact] + public void Create_ShouldNormaliseWindowsLineEndings() + { + var diff = UnifiedDiff.Create("a\r\nb", "a\nb", "x", "y"); + + diff.ShouldBeEmpty(); + } +} diff --git a/CentralConfigGenerator.Core.Tests/Services/VersionSelectorTests.cs b/CentralConfigGenerator.Core.Tests/Services/VersionSelectorTests.cs new file mode 100644 index 0000000..c10c978 --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/Services/VersionSelectorTests.cs @@ -0,0 +1,100 @@ +using CentralConfigGenerator.Core.Services; + +namespace CentralConfigGenerator.Core.Tests.Services; + +public class VersionSelectorTests +{ + private readonly VersionSelector _selector = new(new VersionConflictResolver()); + + [Fact] + public void Select_ShouldReturnTheOnlyVersion() => + _selector + .Select("Pkg", ["1.0.0"], VersionResolutionStrategy.Highest, false) + .ShouldBe("1.0.0"); + + [Fact] + public void Select_ShouldPickTheHighestVersion() => + _selector + .Select("Pkg", ["1.0.0", "2.1.0", "2.0.0"], VersionResolutionStrategy.Highest, false) + .ShouldBe("2.1.0"); + + [Fact] + public void Select_ShouldPickTheLowestVersion() => + _selector + .Select("Pkg", ["1.0.0", "2.1.0"], VersionResolutionStrategy.Lowest, false) + .ShouldBe("1.0.0"); + + [Fact] + public void Select_ShouldDropPrereleasesWhenStableExists() => + _selector + .Select("Pkg", ["2.0.0-beta", "1.9.0"], VersionResolutionStrategy.Highest, true) + .ShouldBe("1.9.0"); + + [Fact] + public void Select_ShouldKeepPrereleasesWhenNoStableExists() => + _selector + .Select("Pkg", ["2.0.0-beta", "2.0.0-alpha"], VersionResolutionStrategy.Highest, true) + .ShouldBe("2.0.0-beta"); + + [Fact] + public void Select_ShouldThrowForTheFailStrategy() => + Should.Throw(() => + _selector.Select("Pkg", ["1.0.0", "2.0.0"], VersionResolutionStrategy.Fail, false) + ); + + [Fact] + public void Select_ShouldThrowWhenNoVersionsAreSupplied() => + Should.Throw(() => + _selector.Select("Pkg", [], VersionResolutionStrategy.Highest, false) + ); + + [Theory] + [InlineData("4.*", true)] + [InlineData("1.2.*", true)] + [InlineData("[4.0.0,)", true)] + [InlineData("1.2.3", false)] + [InlineData("[1.0.0,2.0.0)", false)] + public void IsFloating_ShouldDetectWildcardsAndOpenRanges(string version, bool expected) => + VersionSelector.IsFloating(version).ShouldBe(expected); + + [Fact] + public void Select_ShouldIgnorePrereleaseTagsUnderTheVersionComparisonScope() + { + // VersionComparison.Version compares only the numeric parts, so the release and the + // pre-release of the same number tie and the first candidate wins. + var chosen = _selector.Select( + "Pkg", + ["2.0.0-beta", "2.0.0"], + VersionResolutionStrategy.Highest, + ignorePrerelease: false, + global::NuGet.Versioning.VersionComparison.Version + ); + + chosen.ShouldBeOneOf("2.0.0", "2.0.0-beta"); + } + + [Fact] + public void Select_ShouldRankReleaseAbovePrereleaseUnderVersionRelease() => + _selector + .Select( + "Pkg", + ["2.0.0-beta", "2.0.0"], + VersionResolutionStrategy.Highest, + ignorePrerelease: false, + global::NuGet.Versioning.VersionComparison.VersionRelease + ) + .ShouldBe("2.0.0"); + + [Fact] + public void Select_ShouldFallBackToTheResolverForUnparseableVersions() => + _selector + .Select("Pkg", ["$(SomeProperty)", "$(Other)"], VersionResolutionStrategy.Highest, false) + .ShouldNotBeNullOrEmpty(); + + [Theory] + [InlineData("1.0.0-beta", true)] + [InlineData("1.0.0", false)] + [InlineData("not-a-version", false)] + public void IsPrerelease_ShouldDetectPrereleaseTags(string version, bool expected) => + VersionSelector.IsPrerelease(version).ShouldBe(expected); +} diff --git a/CentralConfigGenerator.Core.Tests/TestSupport/FakeDotNetCli.cs b/CentralConfigGenerator.Core.Tests/TestSupport/FakeDotNetCli.cs new file mode 100644 index 0000000..7a77352 --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/TestSupport/FakeDotNetCli.cs @@ -0,0 +1,75 @@ +using CentralConfigGenerator.Core.Services; +using CentralConfigGenerator.Core.Xml; + +namespace CentralConfigGenerator.Core.Tests.TestSupport; + +/// +/// A scripted dotnet CLI. Tests decide which package versions make the "test run" pass, +/// which is what drives the bisect logic. +/// +public sealed class FakeDotNetCli(Func, bool> testsPass, Func readProps) + : IDotNetCliService +{ + public int RestoreCount { get; private set; } + + public int TestCount { get; private set; } + + public bool IsAvailable { get; set; } = true; + + public Task RunAsync( + string arguments, + string workingDirectory, + TimeSpan? timeout = null, + CancellationToken cancellationToken = default + ) => Task.FromResult(Success()); + + public Task RestoreAsync( + string target, + string workingDirectory, + CancellationToken cancellationToken = default + ) + { + RestoreCount++; + return Task.FromResult(Success()); + } + + public Task BuildAsync( + string target, + string workingDirectory, + CancellationToken cancellationToken = default + ) => Task.FromResult(Success()); + + public Task TestAsync( + string target, + string workingDirectory, + string? filter = null, + CancellationToken cancellationToken = default + ) + { + TestCount++; + + var versions = MsBuildXml.TryParse(readProps(), out var document, out _) + ? PackageReferenceReader.ReadPackageVersions(document) + : new Dictionary(StringComparer.OrdinalIgnoreCase); + + return Task.FromResult(testsPass(versions) ? Success() : Failure()); + } + + private static ProcessResult Success() => + new() + { + ExitCode = 0, + StandardOutput = string.Empty, + StandardError = string.Empty, + Duration = TimeSpan.Zero, + }; + + private static ProcessResult Failure() => + new() + { + ExitCode = 1, + StandardOutput = string.Empty, + StandardError = "tests failed", + Duration = TimeSpan.Zero, + }; +} diff --git a/CentralConfigGenerator.Core.Tests/TestSupport/FakeNuGetFeed.cs b/CentralConfigGenerator.Core.Tests/TestSupport/FakeNuGetFeed.cs new file mode 100644 index 0000000..2003e11 --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/TestSupport/FakeNuGetFeed.cs @@ -0,0 +1,88 @@ +using CentralConfigGenerator.Core.Analysis; +using CentralConfigGenerator.Core.NuGet; +using NuGet.Versioning; + +namespace CentralConfigGenerator.Core.Tests.TestSupport; + +/// A scripted feed so the network-backed rules can be tested deterministically. +public sealed class FakeNuGetFeed : INuGetFeedService +{ + private readonly Dictionary _snapshots = + new(StringComparer.OrdinalIgnoreCase); + + public IReadOnlyList Sources => ["https://fake/index.json"]; + + public FakeNuGetFeed Add( + string packageId, + string[]? versions = null, + bool deprecated = false, + string? alternate = null, + string? license = null, + (string Version, Severity Severity)[]? vulnerabilities = null + ) + { + var parsed = (versions ?? ["1.0.0"]).Select(NuGetVersion.Parse).OrderByDescending(v => v).ToList(); + + _snapshots[packageId] = new PackageMetadataSnapshot + { + PackageId = packageId, + Found = true, + AllVersions = parsed, + LatestStable = parsed.FirstOrDefault(v => !v.IsPrerelease), + LatestIncludingPrerelease = parsed.FirstOrDefault(), + IsDeprecated = deprecated, + DeprecationReasons = deprecated ? ["Legacy"] : [], + AlternatePackageId = alternate, + License = license, + Vulnerabilities = + vulnerabilities + ?.Select(v => new PackageVulnerability + { + Severity = v.Severity, + AdvisoryUrl = $"https://advisory/{packageId}", + AffectedRange = v.Version, + }) + .ToList() ?? [], + }; + + return this; + } + + public Task GetMetadataAsync( + string packageId, + CancellationToken cancellationToken = default + ) => + Task.FromResult( + _snapshots.TryGetValue(packageId, out var snapshot) + ? snapshot + : new PackageMetadataSnapshot { PackageId = packageId, Found = false } + ); + + public async Task> GetMetadataAsync( + IEnumerable packageIds, + int maxParallelism, + IProgress? progress = null, + CancellationToken cancellationToken = default + ) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var id in packageIds) + { + result[id] = await GetMetadataAsync(id, cancellationToken); + } + + return result; + } +} + +/// A package graph service that returns whatever the test hands it. +public sealed class FakePackageGraphService(PackageGraph graph) : IPackageGraphService +{ + public Task LoadAsync( + string target, + string workingDirectory, + bool includeTransitive, + CancellationToken cancellationToken = default + ) => Task.FromResult(graph); +} diff --git a/CentralConfigGenerator.Core.Tests/TestSupport/InMemoryFileSystem.cs b/CentralConfigGenerator.Core.Tests/TestSupport/InMemoryFileSystem.cs new file mode 100644 index 0000000..c811c87 --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/TestSupport/InMemoryFileSystem.cs @@ -0,0 +1,142 @@ +using System.Text; +using CentralConfigGenerator.Core.IO; + +namespace CentralConfigGenerator.Core.Tests.TestSupport; + +/// +/// An in-memory so the file-touching services can be tested without +/// going near a real disk. +/// +public sealed class InMemoryFileSystem : IFileSystem +{ + private readonly Dictionary _files = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _directories = new(StringComparer.OrdinalIgnoreCase); + + public InMemoryFileSystem AddFile(string path, string content) + { + var full = Normalize(path); + _files[full] = new UTF8Encoding(false).GetBytes(content); + AddParentDirectories(full); + return this; + } + + public InMemoryFileSystem AddFile(string path, byte[] bytes) + { + var full = Normalize(path); + _files[full] = bytes; + AddParentDirectories(full); + return this; + } + + public InMemoryFileSystem AddDirectory(string path) + { + var full = Normalize(path); + _directories.Add(full); + AddParentDirectories(full); + return this; + } + + public string ReadText(string path) => + new UTF8Encoding(false).GetString(_files[Normalize(path)]); + + public byte[] ReadBytes(string path) => _files[Normalize(path)]; + + public IReadOnlyCollection Files => _files.Keys; + + public bool FileExists(string path) => _files.ContainsKey(Normalize(path)); + + public bool DirectoryExists(string path) => _directories.Contains(Normalize(path)); + + public void CreateDirectory(string path) => AddDirectory(path); + + public void DeleteFile(string path) => _files.Remove(Normalize(path)); + + public void DeleteDirectory(string path, bool recursive) + { + var prefix = Normalize(path) + Path.DirectorySeparatorChar; + + foreach (var file in _files.Keys.Where(f => f.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)).ToList()) + { + _files.Remove(file); + } + + foreach (var directory in _directories.Where(d => d.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)).ToList()) + { + _directories.Remove(directory); + } + + _directories.Remove(Normalize(path)); + } + + public IReadOnlyList EnumerateFiles(string path, string searchPattern, bool recursive) + { + var prefix = Normalize(path) + Path.DirectorySeparatorChar; + + return _files + .Keys.Where(f => f.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + .Where(f => + recursive + || !f[prefix.Length..].Contains(Path.DirectorySeparatorChar) + ) + .OrderBy(f => f, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + public IReadOnlyList EnumerateDirectories(string path) + { + var prefix = Normalize(path) + Path.DirectorySeparatorChar; + + return _directories + .Where(d => d.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + .Where(d => !d[prefix.Length..].Contains(Path.DirectorySeparatorChar)) + .OrderBy(d => d, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + public Task ReadAllBytesAsync(string path, CancellationToken cancellationToken = default) => + Task.FromResult(_files[Normalize(path)]); + + public Task WriteAllBytesAsync(string path, byte[] bytes, CancellationToken cancellationToken = default) + { + var full = Normalize(path); + _files[full] = bytes; + AddParentDirectories(full); + return Task.CompletedTask; + } + + public Task ReadAllTextAsync(string path, CancellationToken cancellationToken = default) => + Task.FromResult(ReadText(path)); + + public Task WriteAllTextAsync(string path, string contents, CancellationToken cancellationToken = default) + { + AddFile(path, contents); + return Task.CompletedTask; + } + + public void CopyFile(string source, string destination, bool overwrite) + { + var full = Normalize(destination); + _files[full] = _files[Normalize(source)]; + AddParentDirectories(full); + } + + public DateTimeOffset GetLastWriteTime(string path) => DateTimeOffset.UtcNow; + + public long GetFileSize(string path) => _files[Normalize(path)].Length; + + private static string Normalize(string path) => + Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar); + + private void AddParentDirectories(string path) + { + // Every ancestor must be registered, even if a sibling already registered one of them. + for ( + var directory = Path.GetDirectoryName(path); + !string.IsNullOrEmpty(directory); + directory = Path.GetDirectoryName(directory) + ) + { + _directories.Add(directory); + } + } +} diff --git a/CentralConfigGenerator.Core.Tests/TestSupport/Sample.cs b/CentralConfigGenerator.Core.Tests/TestSupport/Sample.cs new file mode 100644 index 0000000..7de402a --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/TestSupport/Sample.cs @@ -0,0 +1,46 @@ +namespace CentralConfigGenerator.Core.Tests.TestSupport; + +/// Reusable project XML fixtures. +public static class Sample +{ + public const string AppProject = """ + + + Exe + net8.0 + enable + + + + + + + """; + + public const string LibProject = """ + + + net8.0 + enable + + + + + 2.12.0 + + + + """; + + public const string PackagesProps = """ + + + true + + + + + + + """; +} diff --git a/CentralConfigGenerator.Core.Tests/Workspace/BatchServiceTests.cs b/CentralConfigGenerator.Core.Tests/Workspace/BatchServiceTests.cs new file mode 100644 index 0000000..d708f80 --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/Workspace/BatchServiceTests.cs @@ -0,0 +1,121 @@ +using CentralConfigGenerator.Core.Migration; +using CentralConfigGenerator.Core.Tests.TestSupport; +using CentralConfigGenerator.Core.Workspace; + +namespace CentralConfigGenerator.Core.Tests.Workspace; + +public class BatchServiceTests +{ + private static readonly string Root = Path.Combine(Path.GetTempPath(), "ccg-batch"); + + [Fact] + public void DiscoverWorkspaces_ShouldReturnOneDirectoryPerSolution() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "repo-a", "A.sln"), "sln") + .AddFile(Path.Combine(Root, "repo-b", "B.slnx"), ""); + + var workspaces = new BatchService(fileSystem).DiscoverWorkspaces( + new BatchOptions { RootDirectory = Root } + ); + + workspaces.Count.ShouldBe(2); + } + + [Fact] + public void DiscoverWorkspaces_ShouldFallBackToProjectDirectories() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "repo-a", "A.csproj"), Sample.AppProject); + + var workspaces = new BatchService(fileSystem).DiscoverWorkspaces( + new BatchOptions { RootDirectory = Root } + ); + + workspaces.ShouldHaveSingleItem(); + } + + [Fact] + public void DiscoverWorkspaces_ShouldRespectMaxDepth() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "a", "b", "c", "d", "Deep.sln"), "sln"); + + var workspaces = new BatchService(fileSystem).DiscoverWorkspaces( + new BatchOptions { RootDirectory = Root, MaxDepth = 2 } + ); + + workspaces.ShouldBeEmpty(); + } + + [Fact] + public void DiscoverWorkspaces_ShouldSkipBuildOutput() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "repo", "A.sln"), "sln") + .AddFile(Path.Combine(Root, "repo", "bin", "Copy.sln"), "sln"); + + var workspaces = new BatchService(fileSystem).DiscoverWorkspaces( + new BatchOptions { RootDirectory = Root } + ); + + workspaces.ShouldHaveSingleItem(); + } + + [Fact] + public async Task RunAsync_ShouldProcessEveryWorkspace() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "repo-a", "A.sln"), "sln") + .AddFile(Path.Combine(Root, "repo-b", "B.sln"), "sln"); + + var result = await new BatchService(fileSystem).RunAsync( + new BatchOptions { RootDirectory = Root }, + (_, _) => Task.FromResult(EmptyPlan()) + ); + + result.SucceededCount.ShouldBe(2); + result.FailedCount.ShouldBe(0); + } + + [Fact] + public async Task RunAsync_ShouldStopAtTheFirstFailureByDefault() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "repo-a", "A.sln"), "sln") + .AddFile(Path.Combine(Root, "repo-b", "B.sln"), "sln"); + + var result = await new BatchService(fileSystem).RunAsync( + new BatchOptions { RootDirectory = Root }, + (_, _) => throw new InvalidOperationException("boom") + ); + + result.Items.Count.ShouldBe(1); + result.Items[0].Error.ShouldBe("boom"); + } + + [Fact] + public async Task RunAsync_ShouldKeepGoingWhenContinueOnErrorIsSet() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "repo-a", "A.sln"), "sln") + .AddFile(Path.Combine(Root, "repo-b", "B.sln"), "sln"); + + var result = await new BatchService(fileSystem).RunAsync( + new BatchOptions { RootDirectory = Root, ContinueOnError = true }, + (_, _) => throw new InvalidOperationException("boom") + ); + + result.FailedCount.ShouldBe(2); + } + + private static MigrationPlan EmptyPlan() => + new() + { + Changes = [], + ResolvedVersions = new Dictionary(), + Conflicts = [], + Findings = [], + ProjectCount = 0, + }; +} diff --git a/CentralConfigGenerator.Core.Tests/Workspace/DependencyTreeServiceTests.cs b/CentralConfigGenerator.Core.Tests/Workspace/DependencyTreeServiceTests.cs new file mode 100644 index 0000000..9b21c6a --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/Workspace/DependencyTreeServiceTests.cs @@ -0,0 +1,80 @@ +using CentralConfigGenerator.Core.NuGet; +using CentralConfigGenerator.Core.Workspace; + +namespace CentralConfigGenerator.Core.Tests.Workspace; + +public class DependencyTreeServiceTests +{ + private static readonly PackageGraph Graph = new() + { + IsAvailable = true, + Packages = + [ + new ResolvedPackage + { + PackageId = "Serilog", + ResolvedVersion = "3.1.1", + RequestedVersion = "3.1.1", + ProjectPath = "/repo/src/App/App.csproj", + TargetFramework = "net8.0", + }, + new ResolvedPackage + { + PackageId = "System.Text.Json", + ResolvedVersion = "8.0.0", + ProjectPath = "/repo/src/App/App.csproj", + TargetFramework = "net8.0", + IsTransitive = true, + }, + ], + }; + + [Fact] + public void Render_ShouldDrawProjectFrameworkAndPackages() + { + var tree = DependencyTreeService.Render(Graph, includeTransitive: true); + + tree.ShouldContain("App"); + tree.ShouldContain("net8.0"); + tree.ShouldContain("Serilog 3.1.1"); + tree.ShouldContain("System.Text.Json 8.0.0 (transitive)"); + } + + [Fact] + public void Render_ShouldOmitTransitivePackagesWhenNotRequested() + { + var tree = DependencyTreeService.Render(Graph, includeTransitive: false); + + tree.ShouldNotContain("System.Text.Json"); + } + + [Fact] + public void Render_ShouldNoteWhenTheResolvedVersionDiffersFromTheRequest() + { + var graph = Graph with + { + Packages = + [ + Graph.Packages[0] with { RequestedVersion = "3.0.0", ResolvedVersion = "3.1.1" }, + ], + }; + + DependencyTreeService.Render(graph, includeTransitive: false) + .ShouldContain("(requested 3.0.0)"); + } + + [Fact] + public async Task RenderAsync_ShouldExplainWhenTheGraphIsUnavailable() + { + var service = new DependencyTreeService( + new TestSupport.FakePackageGraphService( + PackageGraph.Empty with { Error = "restore failed" } + ) + ); + + var output = await service.RenderAsync("/repo", "/repo", includeTransitive: false); + + output.ShouldContain("Dependency graph unavailable"); + output.ShouldContain("restore failed"); + } +} diff --git a/CentralConfigGenerator.Core.Tests/Workspace/PackageGraphServiceTests.cs b/CentralConfigGenerator.Core.Tests/Workspace/PackageGraphServiceTests.cs new file mode 100644 index 0000000..b6b598c --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/Workspace/PackageGraphServiceTests.cs @@ -0,0 +1,105 @@ +using CentralConfigGenerator.Core.NuGet; + +namespace CentralConfigGenerator.Core.Tests.Workspace; + +public class PackageGraphServiceTests +{ + private const string SampleJson = """ + { + "version": 1, + "projects": [ + { + "path": "/repo/src/App/App.csproj", + "frameworks": [ + { + "framework": "net8.0", + "topLevelPackages": [ + { "id": "Serilog", "requestedVersion": "3.1.1", "resolvedVersion": "3.1.1" } + ], + "transitivePackages": [ + { "id": "System.Text.Json", "resolvedVersion": "8.0.0" } + ] + } + ] + } + ] + } + """; + + [Fact] + public void Parse_ShouldReadDirectAndTransitivePackages() + { + var graph = PackageGraphService.Parse(SampleJson); + + graph.IsAvailable.ShouldBeTrue(); + graph.Direct.Count().ShouldBe(1); + graph.Transitive.Count().ShouldBe(1); + graph.Packages[0].TargetFramework.ShouldBe("net8.0"); + } + + [Fact] + public void Parse_ShouldToleratePrefixNoiseBeforeTheJson() + { + var graph = PackageGraphService.Parse("Restoring...\n" + SampleJson); + + graph.IsAvailable.ShouldBeTrue(); + } + + [Fact] + public void Parse_ShouldReportAnErrorForNonJson() + { + var graph = PackageGraphService.Parse("no json here"); + + graph.IsAvailable.ShouldBeFalse(); + graph.Error.ShouldNotBeNull(); + } + + [Fact] + public void Parse_ShouldReportAnErrorForMalformedJson() + { + var graph = PackageGraphService.Parse("{ \"projects\": [ "); + + graph.IsAvailable.ShouldBeFalse(); + } + + [Fact] + public void DivergentPackages_ShouldFindVersionSplits() + { + var graph = new PackageGraph + { + IsAvailable = true, + Packages = + [ + Package("A", "1.0.0", "One.csproj"), + Package("A", "2.0.0", "Two.csproj"), + Package("B", "1.0.0", "One.csproj"), + ], + }; + + graph.DivergentPackages.Select(g => g.Key).ShouldBe(["A"]); + } + + [Fact] + public void Signature_ShouldKeyByProjectFrameworkAndPackage() + { + var graph = new PackageGraph + { + IsAvailable = true, + Packages = [Package("A", "1.0.0", "/repo/One.csproj")], + }; + + var signature = graph.Signature(); + + signature.ShouldContainKey("One.csproj|net8.0|A"); + signature["One.csproj|net8.0|A"].ShouldBe("1.0.0"); + } + + private static ResolvedPackage Package(string id, string version, string project) => + new() + { + PackageId = id, + ResolvedVersion = version, + ProjectPath = project, + TargetFramework = "net8.0", + }; +} diff --git a/CentralConfigGenerator.Core.Tests/Workspace/PackageUpdateServiceTests.cs b/CentralConfigGenerator.Core.Tests/Workspace/PackageUpdateServiceTests.cs new file mode 100644 index 0000000..8cf3acb --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/Workspace/PackageUpdateServiceTests.cs @@ -0,0 +1,204 @@ +using CentralConfigGenerator.Core.Discovery; +using CentralConfigGenerator.Core.Services; +using CentralConfigGenerator.Core.Tests.TestSupport; +using CentralConfigGenerator.Core.Workspace; + +namespace CentralConfigGenerator.Core.Tests.Workspace; + +public class PackageUpdateServiceTests +{ + private static readonly string Root = Path.Combine(Path.GetTempPath(), "ccg-update"); + private static readonly string PropsPath = Path.Combine(Root, "Directory.Packages.props"); + + private const string Props = """ + + + true + + + + + + + + + """; + + private static FakeNuGetFeed Feed() => + new FakeNuGetFeed() + .Add("Alpha", ["1.0.0", "2.0.0"]) + .Add("Beta", ["1.0.0", "2.0.0"]) + .Add("Gamma", ["1.0.0", "2.0.0"]) + .Add("Delta", ["1.0.0", "2.0.0"]); + + [Fact] + public async Task UpdateAsync_ShouldReportCandidatesWithoutWritingOnDryRun() + { + var (service, fileSystem, _) = Create(_ => true); + + var result = await service.UpdateAsync(Options() with { DryRun = true }); + + result.Candidates.Count.ShouldBe(4); + result.Applied.ShouldBeEmpty(); + fileSystem.ReadText(PropsPath).ShouldBe(Props); + } + + [Fact] + public async Task UpdateAsync_ShouldApplyEverythingWhenTestsPass() + { + var (service, fileSystem, cli) = Create(_ => true); + + var result = await service.UpdateAsync(Options()); + + result.Applied.Count.ShouldBe(4); + result.TestsPassed.ShouldBeTrue(); + result.RolledBack.ShouldBeFalse(); + fileSystem.ReadText(PropsPath).ShouldContain("Include=\"Alpha\" Version=\"2.0.0\""); + cli.TestCount.ShouldBe(1); + } + + [Fact] + public async Task UpdateAsync_ShouldRollEverythingBackWhenTestsFail() + { + var (service, fileSystem, _) = Create(_ => false); + + var result = await service.UpdateAsync(Options()); + + result.Applied.ShouldBeEmpty(); + result.RolledBack.ShouldBeTrue(); + result.HeldBack.Count.ShouldBe(4); + fileSystem.ReadText(PropsPath).ShouldBe(Props); + } + + [Fact] + public async Task UpdateAsync_ShouldKeepTheGreenSubsetWhenBisecting() + { + // Gamma is the poison pill: any set containing it fails. + var (service, fileSystem, _) = Create(versions => versions["Gamma"] == "1.0.0"); + + var result = await service.UpdateAsync(Options() with { Bisect = true }); + + result.TestsPassed.ShouldBeTrue(); + result.RolledBack.ShouldBeFalse(); + result.Applied.ShouldNotBeEmpty(); + result.Applied.ShouldNotContain(c => c.PackageId == "Gamma"); + result.HeldBack.ShouldContain(c => c.PackageId == "Gamma"); + fileSystem.ReadText(PropsPath).ShouldContain("Include=\"Gamma\" Version=\"1.0.0\""); + } + + [Fact] + public async Task UpdateAsync_ShouldNameWhatItHeldBack() + { + var (service, _, _) = Create(versions => versions["Gamma"] == "1.0.0"); + + var result = await service.UpdateAsync(Options() with { Bisect = true }); + + result.Message.ShouldNotBeNull().ShouldContain("Gamma"); + } + + [Fact] + public async Task UpdateAsync_ShouldRollBackWhenNoSubsetPasses() + { + var (service, fileSystem, _) = Create(_ => false); + + var result = await service.UpdateAsync(Options() with { Bisect = true }); + + result.RolledBack.ShouldBeTrue(); + fileSystem.ReadText(PropsPath).ShouldBe(Props); + } + + [Fact] + public async Task UpdateAsync_ShouldRespectTheBisectBudget() + { + var (service, _, cli) = Create(versions => versions["Gamma"] == "1.0.0"); + + await service.UpdateAsync(Options() with { Bisect = true, BisectBudget = 3 }); + + cli.TestCount.ShouldBeLessThanOrEqualTo(4); + } + + [Fact] + public async Task UpdateAsync_ShouldRestrictToTheOnlyList() + { + var (service, _, _) = Create(_ => true); + + var result = await service.UpdateAsync(Options() with { Only = ["Alpha"] }); + + result.Candidates.ShouldHaveSingleItem(); + result.Candidates[0].PackageId.ShouldBe("Alpha"); + } + + [Fact] + public async Task UpdateAsync_ShouldSkipTestsWhenAsked() + { + var (service, fileSystem, cli) = Create(_ => false); + + var result = await service.UpdateAsync(Options() with { SkipTests = true }); + + cli.TestCount.ShouldBe(0); + result.Applied.Count.ShouldBe(4); + fileSystem.ReadText(PropsPath).ShouldContain("2.0.0"); + } + + [Fact] + public async Task UpdateAsync_ShouldExplainWhenThereIsNoPropsFile() + { + var fileSystem = new InMemoryFileSystem().AddDirectory(Root); + var service = Build(fileSystem, new FakeDotNetCli(_ => true, () => string.Empty)); + + var result = await service.UpdateAsync(Options()); + + result.Candidates.ShouldBeEmpty(); + result.Message.ShouldNotBeNull().ShouldContain("No Directory.Packages.props"); + } + + [Fact] + public async Task UpdateAsync_ShouldSayWhenEverythingIsCurrent() + { + var fileSystem = new InMemoryFileSystem().AddFile(PropsPath, Props); + var feed = new FakeNuGetFeed() + .Add("Alpha", ["1.0.0"]) + .Add("Beta", ["1.0.0"]) + .Add("Gamma", ["1.0.0"]) + .Add("Delta", ["1.0.0"]); + + var service = new PackageUpdateService( + new ProjectDiscoveryService(fileSystem), + fileSystem, + feed, + new FakeDotNetCli(_ => true, () => fileSystem.ReadText(PropsPath)), + new BackupService(fileSystem) + ); + + var result = await service.UpdateAsync(Options()); + + result.Message.ShouldNotBeNull().ShouldContain("up to date"); + } + + private static PackageUpdateOptions Options() => + new() + { + RootDirectory = Root, + PackagesPropsPath = PropsPath, + BackupDirectory = Root, + }; + + private static (PackageUpdateService Service, InMemoryFileSystem FileSystem, FakeDotNetCli Cli) Create( + Func, bool> testsPass + ) + { + var fileSystem = new InMemoryFileSystem().AddFile(PropsPath, Props); + var cli = new FakeDotNetCli(testsPass, () => fileSystem.ReadText(PropsPath)); + + return (Build(fileSystem, cli), fileSystem, cli); + } + + private static PackageUpdateService Build(InMemoryFileSystem fileSystem, FakeDotNetCli cli) => + new( + new ProjectDiscoveryService(fileSystem), + fileSystem, + Feed(), + cli, + new BackupService(fileSystem) + ); +} diff --git a/CentralConfigGenerator.Core.Tests/Workspace/VerificationServiceTests.cs b/CentralConfigGenerator.Core.Tests/Workspace/VerificationServiceTests.cs new file mode 100644 index 0000000..b51b703 --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/Workspace/VerificationServiceTests.cs @@ -0,0 +1,76 @@ +using CentralConfigGenerator.Core.NuGet; +using CentralConfigGenerator.Core.Services; +using CentralConfigGenerator.Core.Workspace; + +namespace CentralConfigGenerator.Core.Tests.Workspace; + +public class VerificationServiceTests +{ + private readonly VerificationService _service = new( + new TestSupport.FakePackageGraphService(PackageGraph.Empty), + new DotNetCliService() + ); + + [Fact] + public void Compare_ShouldReportNoDriftForIdenticalGraphs() + { + var graph = Graph(("A", "1.0.0")); + + var result = _service.Compare(graph, graph); + + result.IsClean.ShouldBeTrue(); + result.GraphAvailable.ShouldBeTrue(); + } + + [Fact] + public void Compare_ShouldReportChangedVersions() + { + var result = _service.Compare(Graph(("A", "1.0.0")), Graph(("A", "2.0.0"))); + + result.Drift.ShouldHaveSingleItem(); + result.Drift[0].Before.ShouldBe("1.0.0"); + result.Drift[0].After.ShouldBe("2.0.0"); + } + + [Fact] + public void Compare_ShouldReportAdditions() + { + var result = _service.Compare(Graph(("A", "1.0.0")), Graph(("A", "1.0.0"), ("B", "1.0.0"))); + + result.Drift.ShouldHaveSingleItem(); + result.Drift[0].IsAddition.ShouldBeTrue(); + } + + [Fact] + public void Compare_ShouldReportRemovals() + { + var result = _service.Compare(Graph(("A", "1.0.0"), ("B", "1.0.0")), Graph(("A", "1.0.0"))); + + result.Drift.ShouldHaveSingleItem(); + result.Drift[0].IsRemoval.ShouldBeTrue(); + } + + [Fact] + public void Compare_ShouldSurfaceUnavailableGraphs() + { + var result = _service.Compare(PackageGraph.Empty, Graph(("A", "1.0.0"))); + + result.GraphAvailable.ShouldBeFalse(); + result.Error.ShouldNotBeNull(); + } + + private static PackageGraph Graph(params (string Id, string Version)[] packages) => + new() + { + IsAvailable = true, + Packages = packages + .Select(p => new ResolvedPackage + { + PackageId = p.Id, + ResolvedVersion = p.Version, + ProjectPath = "/repo/App.csproj", + TargetFramework = "net8.0", + }) + .ToList(), + }; +} diff --git a/CentralConfigGenerator.Core.Tests/Xml/PackageReferenceReaderTests.cs b/CentralConfigGenerator.Core.Tests/Xml/PackageReferenceReaderTests.cs new file mode 100644 index 0000000..e38dbd5 --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/Xml/PackageReferenceReaderTests.cs @@ -0,0 +1,117 @@ +using CentralConfigGenerator.Core.Models; +using CentralConfigGenerator.Core.Xml; + +namespace CentralConfigGenerator.Core.Tests.Xml; + +public class PackageReferenceReaderTests +{ + [Fact] + public void Read_ShouldPickUpAttributeVersions() + { + var project = Project(""" + + + + """); + + var references = PackageReferenceReader.Read(project); + + references.Count.ShouldBe(1); + references[0].PackageId.ShouldBe("Serilog"); + references[0].Version.ShouldBe("3.1.1"); + references[0].VersionIsElement.ShouldBeFalse(); + } + + [Fact] + public void Read_ShouldPickUpChildElementVersions() + { + var project = Project(""" + + + 2.12.0 + + + """); + + var references = PackageReferenceReader.Read(project); + + references[0].Version.ShouldBe("2.12.0"); + references[0].VersionIsElement.ShouldBeTrue(); + } + + [Fact] + public void Read_ShouldPickUpGlobalPackageReferences() + { + var project = Project(""" + + + + """); + + var references = PackageReferenceReader.Read(project); + + references[0].IsGlobal.ShouldBeTrue(); + } + + [Fact] + public void Read_ShouldCaptureInheritedConditions() + { + var project = Project(""" + + + + """); + + PackageReferenceReader.Read(project)[0].Condition.ShouldNotBeNull().ShouldContain("net8.0"); + } + + [Fact] + public void Read_ShouldReportPrivateAssets() + { + var project = Project(""" + + + + """); + + PackageReferenceReader.Read(project)[0].IsPrivateAssets.ShouldBeTrue(); + } + + [Fact] + public void Read_ShouldReturnEmptyForUnparseableXml() + { + var project = new ProjectFile { Path = "Broken.csproj", Content = "" }; + + PackageReferenceReader.Read(project).ShouldBeEmpty(); + } + + [Fact] + public void Read_ShouldTreatVersionlessReferencesAsNullVersion() + { + var project = Project(""" + + + + """); + + PackageReferenceReader.Read(project)[0].Version.ShouldBeNull(); + } + + [Fact] + public void ReadPackageVersions_ShouldReadCentralEntries() + { + var document = MsBuildXml.Parse(TestSupport.Sample.PackagesProps); + + var versions = PackageReferenceReader.ReadPackageVersions(document); + + versions.Count.ShouldBe(2); + versions["newtonsoft.json"].ShouldBe("13.0.3"); + } + + private static ProjectFile Project(string body) => + new() + { + Path = Path.Combine(Path.GetTempPath(), "Test.csproj"), + Content = $"\n{body}\n", + }; +} diff --git a/CentralConfigGenerator.Core/Analysis/AnalysisContext.cs b/CentralConfigGenerator.Core/Analysis/AnalysisContext.cs new file mode 100644 index 0000000..0277578 --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/AnalysisContext.cs @@ -0,0 +1,62 @@ +using CentralConfigGenerator.Core.Models; +using CentralConfigGenerator.Core.NuGet; + +namespace CentralConfigGenerator.Core.Analysis; + +/// Everything the rules read from. Built once per run, then shared by all analyzers. +public sealed record AnalysisContext +{ + public required AnalysisOptions Options { get; init; } + + public required IReadOnlyList Projects { get; init; } + + public required IReadOnlyList References { get; init; } + + /// Versions declared in Directory.Packages.props, empty when the file is absent. + public required IReadOnlyDictionary CentralVersions { get; init; } + + public bool CentralPackageManagementEnabled { get; init; } + + public bool TransitivePinningEnabled { get; init; } + + public string? PackagesPropsPath { get; init; } + + public IReadOnlyDictionary Metadata { get; init; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + public PackageGraph Graph { get; init; } = PackageGraph.Empty; + + /// Target frameworks declared per project, used by the framework alignment rule. + public IReadOnlyDictionary> TargetFrameworks { get; init; } = + new Dictionary>(StringComparer.OrdinalIgnoreCase); + + public IEnumerable RelevantReferences => + References.Where(r => !IsIgnored(r.PackageId)); + + public bool IsIgnored(string packageId) => + Options.IgnorePackages.Any(pattern => + pattern.EndsWith('*') + ? packageId.StartsWith(pattern[..^1], StringComparison.OrdinalIgnoreCase) + : string.Equals(pattern, packageId, StringComparison.OrdinalIgnoreCase) + ); + + public PackageMetadataSnapshot? MetadataFor(string packageId) => + Metadata.TryGetValue(packageId, out var snapshot) ? snapshot : null; + + /// The version a package effectively resolves to: central entry first, then inline. + public string? EffectiveVersion(string packageId) + { + if (CentralVersions.TryGetValue(packageId, out var central)) + { + return central; + } + + return References + .Where(r => + string.Equals(r.PackageId, packageId, StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrWhiteSpace(r.Version) + ) + .Select(r => r.Version) + .FirstOrDefault(); + } +} diff --git a/CentralConfigGenerator.Core/Analysis/AnalysisEngine.cs b/CentralConfigGenerator.Core/Analysis/AnalysisEngine.cs new file mode 100644 index 0000000..e02c6d0 --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/AnalysisEngine.cs @@ -0,0 +1,353 @@ +using System.Diagnostics; +using System.Xml.Linq; +using CentralConfigGenerator.Core.Discovery; +using CentralConfigGenerator.Core.IO; +using CentralConfigGenerator.Core.Models; +using CentralConfigGenerator.Core.NuGet; +using CentralConfigGenerator.Core.Xml; + +namespace CentralConfigGenerator.Core.Analysis; + +public interface IAnalysisEngine +{ + Task AnalyzeAsync( + AnalysisOptions options, + IProgress? progress = null, + CancellationToken cancellationToken = default + ); + + Task BuildContextAsync( + AnalysisOptions options, + IProgress? progress = null, + CancellationToken cancellationToken = default + ); +} + +/// +/// Builds the analysis context once, runs every enabled rule against it, then applies severity +/// overrides and the baseline. +/// +public sealed class AnalysisEngine( + IProjectDiscoveryService discovery, + IFileSystem fileSystem, + INuGetFeedService feedService, + IPackageGraphService graphService, + IBaselineService baselineService +) : IAnalysisEngine +{ + public async Task AnalyzeAsync( + AnalysisOptions options, + IProgress? progress = null, + CancellationToken cancellationToken = default + ) + { + var stopwatch = Stopwatch.StartNew(); + var context = await BuildContextAsync(options, progress, cancellationToken); + + if (context.Projects.Count == 0) + { + return new AnalysisReport + { + Findings = [], + ProjectCount = 0, + PackageCount = 0, + Duration = stopwatch.Elapsed, + RootDirectory = options.RootDirectory, + }; + } + + var findings = new List(); + var incompletions = new List(); + + foreach (var rule in RuleCatalog.CreateAll()) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!IsRuleActive(rule, options, context)) + { + continue; + } + + progress?.Report(rule.RuleId); + + try + { + foreach (var finding in rule.Analyze(context)) + { + findings.Add(ApplySeverityOverride(finding, options)); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + incompletions.Add($"{rule.RuleId} failed: {ex.Message}"); + } + } + + if (options.RequiresNetwork) + { + var unreachable = context + .Metadata.Values.Where(m => m.Error is not null) + .Select(m => $"{m.PackageId}: {m.Error}") + .ToList(); + + if (unreachable.Count > 0) + { + incompletions.Add( + $"{unreachable.Count} package(s) could not be checked against the feed." + ); + } + } + + if (options.Transitive && !context.Graph.IsAvailable) + { + incompletions.Add( + $"Transitive graph unavailable: {context.Graph.Error ?? "unknown reason"}." + ); + } + + var baseline = options.BaselinePath is null + ? null + : await baselineService.LoadAsync(options.BaselinePath, cancellationToken); + + var filtered = baselineService.Apply(findings, baseline); + + var ordered = filtered + .OrderByDescending(f => f.Severity) + .ThenBy(f => f.RuleId, StringComparer.Ordinal) + .ThenBy(f => f.PackageId ?? string.Empty, StringComparer.OrdinalIgnoreCase) + .ToList(); + + return new AnalysisReport + { + Findings = ordered, + ProjectCount = context.Projects.Count, + PackageCount = context + .References.Select(r => r.PackageId) + .Concat(context.CentralVersions.Keys) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Count(), + Duration = stopwatch.Elapsed, + IsIncomplete = incompletions.Count > 0, + Incompletions = incompletions, + RootDirectory = options.RootDirectory, + SuppressedCount = findings.Count - filtered.Count, + }; + } + + public async Task BuildContextAsync( + AnalysisOptions options, + IProgress? progress = null, + CancellationToken cancellationToken = default + ) + { + progress?.Report("Discovering projects"); + + var projects = await discovery.DiscoverAsync( + new DiscoveryOptions + { + RootDirectory = options.RootDirectory, + SolutionPath = options.SolutionPath, + ProjectPath = options.ProjectPath, + ExcludePattern = options.ExcludePattern, + }, + cancellationToken + ); + + var references = projects.SelectMany(PackageReferenceReader.Read).ToList(); + var frameworks = ReadTargetFrameworks(projects); + + var (centralVersions, cpmEnabled, pinning, propsPath) = await ReadCentralFileAsync( + options, + cancellationToken + ); + + var graph = PackageGraph.Empty; + if (options.Transitive) + { + progress?.Report("Resolving transitive graph"); + graph = await graphService.LoadAsync( + options.SolutionPath ?? options.ProjectPath ?? options.RootDirectory, + options.RootDirectory, + includeTransitive: true, + cancellationToken + ); + } + + IReadOnlyDictionary metadata = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (options.RequiresNetwork) + { + var ids = references + .Select(r => r.PackageId) + .Concat(centralVersions.Keys) + .Concat(graph.Transitive.Select(p => p.PackageId)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + progress?.Report($"Querying {ids.Count} packages"); + + metadata = await feedService.GetMetadataAsync( + ids, + options.MaxParallelism, + progress, + cancellationToken + ); + } + + return new AnalysisContext + { + Options = options, + Projects = projects, + References = references, + CentralVersions = centralVersions, + CentralPackageManagementEnabled = cpmEnabled, + TransitivePinningEnabled = pinning, + PackagesPropsPath = propsPath, + Metadata = metadata, + Graph = graph, + TargetFrameworks = frameworks, + }; + } + + private static bool IsRuleActive( + IAnalysisRule rule, + AnalysisOptions options, + AnalysisContext context + ) + { + if ( + options.RuleOverrides.TryGetValue(rule.RuleId, out var severity) + && severity is null + ) + { + return false; + } + + if (!rule.IsEnabled(options)) + { + return false; + } + + if (rule.RequiresNetwork && !options.RequiresNetwork) + { + return false; + } + + return !rule.RequiresCentralPackageManagement || context.CentralPackageManagementEnabled; + } + + private static Finding ApplySeverityOverride(Finding finding, AnalysisOptions options) => + options.RuleOverrides.TryGetValue(finding.RuleId, out var severity) && severity is not null + ? finding with { Severity = severity.Value } + : finding; + + private async Task<( + IReadOnlyDictionary Versions, + bool CpmEnabled, + bool Pinning, + string? Path + )> ReadCentralFileAsync(AnalysisOptions options, CancellationToken cancellationToken) + { + var empty = new Dictionary(StringComparer.OrdinalIgnoreCase); + var path = FindPackagesProps(options); + + if (path is null) + { + return (empty, false, false, null); + } + + var file = await discovery.LoadAsync(path, cancellationToken); + if (file is null || !MsBuildXml.TryParse(file.Content, out var document, out _)) + { + return (empty, false, false, path); + } + + var versions = PackageReferenceReader.ReadPackageVersions(document); + + return ( + versions, + ReadBool(document, "ManagePackageVersionsCentrally"), + ReadBool(document, "CentralPackageTransitivePinningEnabled"), + path + ); + } + + private string? FindPackagesProps(AnalysisOptions options) + { + var start = + options.SolutionPath is not null + ? Path.GetDirectoryName(Path.GetFullPath(options.SolutionPath)) + : Path.GetFullPath(options.RootDirectory); + + var directory = start is null ? null : new DirectoryInfo(start); + + while (directory is not null) + { + var candidate = Path.Combine(directory.FullName, "Directory.Packages.props"); + if (fileSystem.FileExists(candidate)) + { + return candidate; + } + + directory = directory.Parent; + } + + return null; + } + + private static bool ReadBool(XDocument document, string propertyName) => + MsBuildXml + .Descendants(document, propertyName) + .Select(e => e.Value.Trim()) + .Any(v => string.Equals(v, "true", StringComparison.OrdinalIgnoreCase)); + + private static IReadOnlyDictionary> ReadTargetFrameworks( + IReadOnlyList projects + ) + { + var result = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var project in projects) + { + if (!MsBuildXml.TryParse(project.Content, out var document, out _)) + { + continue; + } + + var frameworks = new List(); + + foreach (var element in MsBuildXml.Descendants(document, "TargetFramework")) + { + var value = element.Value.Trim(); + if (value.Length > 0 && !value.Contains('$')) + { + frameworks.Add(value); + } + } + + foreach (var element in MsBuildXml.Descendants(document, "TargetFrameworks")) + { + foreach ( + var value in element.Value.Split( + ';', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries + ) + ) + { + if (!value.Contains('$')) + { + frameworks.Add(value); + } + } + } + + if (frameworks.Count > 0) + { + result[project.Path] = frameworks.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + } + } + + return result; + } +} diff --git a/CentralConfigGenerator.Core/Analysis/AnalysisOptions.cs b/CentralConfigGenerator.Core/Analysis/AnalysisOptions.cs new file mode 100644 index 0000000..a60665e --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/AnalysisOptions.cs @@ -0,0 +1,45 @@ +namespace CentralConfigGenerator.Core.Analysis; + +/// Controls which analyzers run and how strict the run is. +public sealed record AnalysisOptions +{ + public string RootDirectory { get; init; } = Directory.GetCurrentDirectory(); + + public string? SolutionPath { get; init; } + + public string? ProjectPath { get; init; } + + public string? ExcludePattern { get; init; } + + /// Query the feed for known security advisories. + public bool Audit { get; init; } + + /// Report packages that are behind the newest published version. + public bool Outdated { get; init; } + + /// Report packages the author marked deprecated. + public bool Deprecated { get; init; } + + /// Flag copyleft and unknown licences. + public bool Licenses { get; init; } + + /// Resolve the transitive graph via dotnet list package. + public bool Transitive { get; init; } + + public int MaxParallelism { get; init; } = 8; + + public Severity FailOn { get; init; } = Severity.Info; + + /// Per-rule severity overrides. A null value disables the rule entirely. + public IReadOnlyDictionary RuleOverrides { get; init; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + public string? BaselinePath { get; init; } + + public IReadOnlyCollection IgnorePackages { get; init; } = []; + + public bool IncludePrerelease { get; init; } + + /// True when any rule that needs the network is enabled. + public bool RequiresNetwork => Audit || Outdated || Deprecated || Licenses; +} diff --git a/CentralConfigGenerator.Core/Analysis/AnalysisReport.cs b/CentralConfigGenerator.Core/Analysis/AnalysisReport.cs new file mode 100644 index 0000000..fca659d --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/AnalysisReport.cs @@ -0,0 +1,71 @@ +namespace CentralConfigGenerator.Core.Analysis; + +/// Aggregated result of a dependency health analysis run. +public sealed record AnalysisReport +{ + public required IReadOnlyList Findings { get; init; } + + public required int ProjectCount { get; init; } + + public required int PackageCount { get; init; } + + public TimeSpan Duration { get; init; } + + /// True when part of the scan could not complete (for example the feed was unreachable). + public bool IsIncomplete { get; init; } + + public IReadOnlyList Incompletions { get; init; } = []; + + public string RootDirectory { get; init; } = string.Empty; + + public int SuppressedCount { get; init; } + + public Severity HighestSeverity => + Findings.Count == 0 ? Severity.Never : Findings.Max(f => f.Severity); + + public int CountOf(Severity severity) => Findings.Count(f => f.Severity == severity); + + /// + /// A 0-100 dependency health score. Every finding subtracts a weight scaled by severity, + /// so a clean workspace scores 100. + /// + public int HealthScore + { + get + { + if (PackageCount == 0) + { + return 100; + } + + double penalty = 0; + foreach (var finding in Findings) + { + penalty += finding.Severity switch + { + Severity.Critical => 25, + Severity.High => 12, + Severity.Moderate => 5, + Severity.Low => 2, + Severity.Info => 0.5, + _ => 0, + }; + } + + // Normalise against workspace size so large solutions are not punished twice. + var scale = Math.Max(1.0, Math.Sqrt(PackageCount) / 3.0); + var score = 100 - (penalty / scale); + return (int)Math.Round(Math.Clamp(score, 0, 100)); + } + } + + public string Grade => + HealthScore switch + { + >= 90 => "A", + >= 80 => "B", + >= 70 => "C", + >= 60 => "D", + _ => "F", + }; +} diff --git a/CentralConfigGenerator.Core/Analysis/AutoFixService.cs b/CentralConfigGenerator.Core/Analysis/AutoFixService.cs new file mode 100644 index 0000000..7b15ea2 --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/AutoFixService.cs @@ -0,0 +1,267 @@ +using CentralConfigGenerator.Core.Discovery; +using CentralConfigGenerator.Core.Generators; +using CentralConfigGenerator.Core.IO; +using CentralConfigGenerator.Core.Migration; +using CentralConfigGenerator.Core.Models; +using CentralConfigGenerator.Core.Services.Abstractions; + +namespace CentralConfigGenerator.Core.Analysis; + +/// The outcome of trying to auto-fix a set of findings. +public sealed record AutoFixResult +{ + public required IReadOnlyList Changes { get; init; } + + public required IReadOnlyList Fixed { get; init; } + + public required IReadOnlyList Skipped { get; init; } + + public BackupSet? Backup { get; init; } + + public bool HasChanges => Changes.Count > 0; +} + +public interface IAutoFixService +{ + Task PlanAsync( + AnalysisContext context, + IReadOnlyList findings, + CancellationToken cancellationToken = default + ); + + Task ApplyAsync( + AutoFixResult result, + string rootDirectory, + string? backupDirectory, + bool createBackup, + CancellationToken cancellationToken = default + ); +} + +/// +/// Turns auto-fixable findings into concrete file edits. Everything is planned in memory first so +/// --fix-dry-run and --fix share the same code path. +/// +public sealed class AutoFixService( + IProjectDiscoveryService discovery, + IFileSystem fileSystem, + IBackupService backupService +) : IAutoFixService +{ + public async Task PlanAsync( + AnalysisContext context, + IReadOnlyList findings, + CancellationToken cancellationToken = default + ) + { + // Working copies keyed by path, so several fixes can stack on the same file. + var buffers = new Dictionary( + StringComparer.OrdinalIgnoreCase + ); + + foreach (var project in context.Projects) + { + buffers[project.Path] = (project.Content, project.Content, project.Format); + } + + var propsPath = context.PackagesPropsPath; + if (propsPath is not null && !buffers.ContainsKey(propsPath)) + { + var props = await discovery.LoadAsync(propsPath, cancellationToken); + if (props is not null) + { + buffers[propsPath] = (props.Content, props.Content, props.Format); + } + } + + var fixedFindings = new List(); + var skipped = new List(); + + foreach (var finding in findings.OrderByDescending(f => f.Severity)) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!finding.IsAutoFixable) + { + skipped.Add(finding); + continue; + } + + if (TryApply(finding, buffers, propsPath)) + { + fixedFindings.Add(finding); + } + else + { + skipped.Add(finding); + } + } + + var changes = buffers + .Where(b => !string.Equals(b.Value.Original, b.Value.Current, StringComparison.Ordinal)) + .Select(b => new FileChange + { + Path = b.Key, + Kind = FileChangeKind.Modified, + OriginalContent = b.Value.Original, + NewContent = b.Value.Current, + Format = b.Value.Format, + }) + .OrderBy(c => c.Path, StringComparer.OrdinalIgnoreCase) + .ToList(); + + return new AutoFixResult + { + Changes = changes, + Fixed = fixedFindings, + Skipped = skipped, + }; + } + + public async Task ApplyAsync( + AutoFixResult result, + string rootDirectory, + string? backupDirectory, + bool createBackup, + CancellationToken cancellationToken = default + ) + { + if (!result.HasChanges) + { + return null; + } + + BackupSet? backup = null; + + if (createBackup) + { + backup = await backupService.CreateAsync( + rootDirectory, + backupDirectory ?? rootDirectory, + "analyze --fix", + result.Changes.Select(c => c.Path), + cancellationToken + ); + } + + foreach (var change in result.Changes) + { + var bytes = EncodingDetector.Encode(change.NewContent, change.Format); + await fileSystem.WriteAllBytesAsync(change.Path, bytes, cancellationToken); + } + + return backup; + } + + private static bool TryApply( + Finding finding, + Dictionary buffers, + string? propsPath + ) + { + switch (finding.Fix) + { + case FixKind.RemoveInlineVersion: + return finding.ProjectPath is not null + && finding.PackageId is not null + && Edit( + buffers, + finding.ProjectPath, + content => + ProjectRewriter.StripVersion( + Wrap(finding.ProjectPath, content), + finding.PackageId + ) + ); + + case FixKind.SetCentralVersion: + return propsPath is not null + && finding.PackageId is not null + && finding.FixValue is not null + && Edit( + buffers, + propsPath, + content => + PackagesPropsEditor.SetVersion( + content, + finding.PackageId, + finding.FixValue + ) + ); + + case FixKind.RemoveOrphanedPackageVersion: + return propsPath is not null + && finding.PackageId is not null + && Edit( + buffers, + propsPath, + content => PackagesPropsEditor.RemoveEntry(content, finding.PackageId) + ); + + case FixKind.RemoveDuplicateReference: + return finding.ProjectPath is not null + && finding.PackageId is not null + && Edit( + buffers, + finding.ProjectPath, + content => + ProjectRewriter.RemoveDuplicateReference( + Wrap(finding.ProjectPath, content), + finding.PackageId + ) + ); + + case FixKind.NormalizePackageCasing: + { + if (finding.FixValue is null) + { + return false; + } + + var canonical = finding.FixValue; + var changed = false; + + foreach (var path in buffers.Keys.ToList()) + { + changed |= Edit( + buffers, + path, + content => + string.Equals(path, propsPath, StringComparison.OrdinalIgnoreCase) + ? PackagesPropsEditor.NormalizeCasing(content, canonical) + : ProjectRewriter.NormalizeCasing(Wrap(path, content), canonical) + ); + } + + return changed; + } + + default: + return false; + } + } + + private static ProjectFile Wrap(string path, string content) => + new() { Path = path, Content = content }; + + private static bool Edit( + Dictionary buffers, + string path, + Func transform + ) + { + if (!buffers.TryGetValue(path, out var buffer)) + { + return false; + } + + var updated = transform(buffer.Current); + if (updated is null) + { + return false; + } + + buffers[path] = (buffer.Original, updated, buffer.Format); + return true; + } +} diff --git a/CentralConfigGenerator.Core/Analysis/BaselineService.cs b/CentralConfigGenerator.Core/Analysis/BaselineService.cs new file mode 100644 index 0000000..6cf9bfb --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/BaselineService.cs @@ -0,0 +1,88 @@ +using System.Text.Json; +using CentralConfigGenerator.Core.IO; + +namespace CentralConfigGenerator.Core.Analysis; + +/// A recorded set of accepted findings, used to keep CI green while debt is paid down. +public sealed record Baseline +{ + public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow; + + public List Fingerprints { get; init; } = []; +} + +public interface IBaselineService +{ + Task LoadAsync(string path, CancellationToken cancellationToken = default); + + Task WriteAsync( + string path, + IEnumerable findings, + CancellationToken cancellationToken = default + ); + + IReadOnlyList Apply(IReadOnlyList findings, Baseline? baseline); +} + +public sealed class BaselineService(IFileSystem fileSystem) : IBaselineService +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + public async Task LoadAsync( + string path, + CancellationToken cancellationToken = default + ) + { + if (!fileSystem.FileExists(path)) + { + return null; + } + + try + { + var json = await fileSystem.ReadAllTextAsync(path, cancellationToken); + return JsonSerializer.Deserialize(json, JsonOptions); + } + catch (JsonException) + { + return null; + } + } + + public async Task WriteAsync( + string path, + IEnumerable findings, + CancellationToken cancellationToken = default + ) + { + var baseline = new Baseline + { + Fingerprints = findings + .Select(f => f.Fingerprint) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .ToList(), + }; + + await fileSystem.WriteAllTextAsync( + path, + JsonSerializer.Serialize(baseline, JsonOptions), + cancellationToken + ); + } + + public IReadOnlyList Apply(IReadOnlyList findings, Baseline? baseline) + { + if (baseline is null || baseline.Fingerprints.Count == 0) + { + return findings; + } + + var accepted = baseline.Fingerprints.ToHashSet(StringComparer.Ordinal); + return findings.Where(f => !accepted.Contains(f.Fingerprint)).ToList(); + } +} diff --git a/CentralConfigGenerator.Core/Analysis/Finding.cs b/CentralConfigGenerator.Core/Analysis/Finding.cs new file mode 100644 index 0000000..1b9c6dd --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/Finding.cs @@ -0,0 +1,64 @@ +namespace CentralConfigGenerator.Core.Analysis; + +/// What an auto-fix should do when --fix is supplied. +public enum FixKind +{ + None = 0, + + /// Remove an inline Version attribute from a project's PackageReference. + RemoveInlineVersion, + + /// Set (or add) a PackageVersion entry in Directory.Packages.props. + SetCentralVersion, + + /// Delete a duplicate PackageReference element. + RemoveDuplicateReference, + + /// Rewrite a package id so its casing matches the canonical spelling. + NormalizePackageCasing, + + /// Remove an unused PackageVersion entry. + RemoveOrphanedPackageVersion, +} + +/// A single analysis result. +public sealed record Finding +{ + public required string RuleId { get; init; } + + public required Severity Severity { get; init; } + + public required string Message { get; init; } + + public string? PackageId { get; init; } + + public string? Version { get; init; } + + public string? ProjectPath { get; init; } + + /// + /// What the finding is about when it is not a package — an MSBuild property name, for + /// example. Keeps fingerprints unique for rules that report several findings per project. + /// + public string? Subject { get; init; } + + public int Line { get; init; } + + public string? Recommendation { get; init; } + + public FixKind Fix { get; init; } = FixKind.None; + + /// Target value for the fix (for example the version to pin to, or the canonical id). + public string? FixValue { get; init; } + + public bool IsAutoFixable => Fix != FixKind.None; + + /// Stable key used for baseline suppression. + public string Fingerprint => + $"{RuleId}|{PackageId ?? Subject ?? "-"}|{NormalizePath(ProjectPath)}|{Version ?? "-"}"; + + private static string NormalizePath(string? path) => + string.IsNullOrEmpty(path) + ? "-" + : Path.GetFileName(path); +} diff --git a/CentralConfigGenerator.Core/Analysis/IAnalysisRule.cs b/CentralConfigGenerator.Core/Analysis/IAnalysisRule.cs new file mode 100644 index 0000000..f5ceb7b --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/IAnalysisRule.cs @@ -0,0 +1,27 @@ +namespace CentralConfigGenerator.Core.Analysis; + +/// One dependency-health check. +public interface IAnalysisRule +{ + string RuleId { get; } + + string Title { get; } + + /// What the rule looks for, shown by the explain command. + string Description { get; } + + /// Why it matters and what to do about it. + string Rationale { get; } + + Severity DefaultSeverity { get; } + + /// True when the rule needs feed metadata to produce results. + bool RequiresNetwork => false; + + /// True when the rule only applies once central package management is in play. + bool RequiresCentralPackageManagement => false; + + bool IsEnabled(AnalysisOptions options) => true; + + IEnumerable Analyze(AnalysisContext context); +} diff --git a/CentralConfigGenerator.Core/Analysis/RuleCatalog.cs b/CentralConfigGenerator.Core/Analysis/RuleCatalog.cs new file mode 100644 index 0000000..7e5551c --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/RuleCatalog.cs @@ -0,0 +1,31 @@ +using CentralConfigGenerator.Core.Analysis.Rules; + +namespace CentralConfigGenerator.Core.Analysis; + +/// The complete set of analysis rules, and lookups used by the explain command. +public static class RuleCatalog +{ + public static IReadOnlyList CreateAll() => + [ + new SecurityVulnerabilityRule(), + new InlineVersionUnderCpmRule(), + new MissingCentralVersionRule(), + new DeprecatedPackageRule(), + new LicenseRiskRule(), + new VersionInconsistencyRule(), + new TransitiveConflictRule(), + new UnpinnedTransitiveDependencyRule(), + new FloatingVersionRule(), + new RedundantReferenceRule(), + new DuplicatePackageCasingRule(), + new OutdatedPackageRule(), + new PrereleaseInProductionRule(), + new OrphanedPackageVersionRule(), + new FrameworkAlignmentRule(), + new PropertyDriftRule(), + ]; + + public static IAnalysisRule? Find(string ruleId) => + CreateAll() + .FirstOrDefault(r => string.Equals(r.RuleId, ruleId, StringComparison.OrdinalIgnoreCase)); +} diff --git a/CentralConfigGenerator.Core/Analysis/RuleIds.cs b/CentralConfigGenerator.Core/Analysis/RuleIds.cs new file mode 100644 index 0000000..6c29579 --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/RuleIds.cs @@ -0,0 +1,42 @@ +namespace CentralConfigGenerator.Core.Analysis; + +/// Stable identifiers for every analysis rule, usable with explain and --rules. +public static class RuleIds +{ + public const string SecurityVulnerability = "SecurityVulnerability"; + public const string InlineVersionUnderCpm = "InlineVersionUnderCpm"; + public const string LicenseRisk = "LicenseRisk"; + public const string VersionInconsistency = "VersionInconsistency"; + public const string FloatingVersion = "FloatingVersion"; + public const string TransitiveConflict = "TransitiveConflict"; + public const string DuplicatePackageCasing = "DuplicatePackageCasing"; + public const string RedundantReference = "RedundantReference"; + public const string OutdatedPackage = "OutdatedPackage"; + public const string DeprecatedPackage = "DeprecatedPackage"; + public const string FrameworkAlignment = "FrameworkAlignment"; + public const string PrereleaseInProduction = "PrereleaseInProduction"; + public const string MissingCentralVersion = "MissingCentralVersion"; + public const string OrphanedPackageVersion = "OrphanedPackageVersion"; + public const string UnpinnedTransitiveDependency = "UnpinnedTransitiveDependency"; + public const string PropertyDrift = "PropertyDrift"; + + public static IReadOnlyList All { get; } = + [ + SecurityVulnerability, + InlineVersionUnderCpm, + LicenseRisk, + VersionInconsistency, + FloatingVersion, + TransitiveConflict, + DuplicatePackageCasing, + RedundantReference, + OutdatedPackage, + DeprecatedPackage, + FrameworkAlignment, + PrereleaseInProduction, + MissingCentralVersion, + OrphanedPackageVersion, + UnpinnedTransitiveDependency, + PropertyDrift, + ]; +} diff --git a/CentralConfigGenerator.Core/Analysis/Rules/DeprecatedPackageRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/DeprecatedPackageRule.cs new file mode 100644 index 0000000..ee7e9c9 --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/Rules/DeprecatedPackageRule.cs @@ -0,0 +1,48 @@ +namespace CentralConfigGenerator.Core.Analysis.Rules; + +/// Reports packages the author has marked deprecated on the feed. +public sealed class DeprecatedPackageRule : IAnalysisRule +{ + public string RuleId => RuleIds.DeprecatedPackage; + + public string Title => "Deprecated package"; + + public string Description => "The package author marked this package as deprecated."; + + public string Rationale => + "Deprecated packages stop receiving fixes, including security fixes. The feed usually " + + "names a replacement, so the migration path is known."; + + public Severity DefaultSeverity => Severity.High; + + public bool RequiresNetwork => true; + + public bool IsEnabled(AnalysisOptions options) => options.Deprecated; + + public IEnumerable Analyze(AnalysisContext context) + { + foreach (var (packageId, snapshot) in context.Metadata) + { + if (!snapshot.IsDeprecated || context.IsIgnored(packageId)) + { + continue; + } + + var reasons = snapshot.DeprecationReasons.Count > 0 + ? string.Join(", ", snapshot.DeprecationReasons) + : "no reason given"; + + yield return new Finding + { + RuleId = RuleId, + Severity = DefaultSeverity, + PackageId = packageId, + Version = context.EffectiveVersion(packageId), + Message = $"'{packageId}' is deprecated ({reasons}).", + Recommendation = snapshot.AlternatePackageId is { } alternate + ? $"Migrate to '{alternate}'." + : snapshot.DeprecationMessage ?? "Find a maintained replacement.", + }; + } + } +} diff --git a/CentralConfigGenerator.Core/Analysis/Rules/DuplicatePackageCasingRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/DuplicatePackageCasingRule.cs new file mode 100644 index 0000000..df914c7 --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/Rules/DuplicatePackageCasingRule.cs @@ -0,0 +1,52 @@ +namespace CentralConfigGenerator.Core.Analysis.Rules; + +/// Flags package ids that differ only by letter case. +public sealed class DuplicatePackageCasingRule : IAnalysisRule +{ + public string RuleId => RuleIds.DuplicatePackageCasing; + + public string Title => "Package id casing mismatch"; + + public string Description => + "The same package is spelled with different capitalisation in different projects."; + + public string Rationale => + "NuGet ids are case-insensitive but MSBuild item matching is not always: mismatched " + + "casing produces duplicate PackageVersion entries and confusing diffs."; + + public Severity DefaultSeverity => Severity.Low; + + public IEnumerable Analyze(AnalysisContext context) + { + var groups = context + .RelevantReferences.Select(r => r.PackageId) + .Concat(context.CentralVersions.Keys.Where(k => !context.IsIgnored(k))) + .GroupBy(id => id, StringComparer.OrdinalIgnoreCase); + + foreach (var group in groups) + { + var spellings = group.Distinct(StringComparer.Ordinal).ToList(); + if (spellings.Count < 2) + { + continue; + } + + // Prefer the feed's canonical spelling when it is known. + var canonical = + context.MetadataFor(group.Key)?.PackageId + ?? spellings.OrderBy(s => s, StringComparer.Ordinal).First(); + + yield return new Finding + { + RuleId = RuleId, + Severity = DefaultSeverity, + PackageId = canonical, + Message = + $"'{group.Key}' appears with {spellings.Count} spellings: {string.Join(", ", spellings)}.", + Recommendation = $"Normalise every reference to '{canonical}'.", + Fix = FixKind.NormalizePackageCasing, + FixValue = canonical, + }; + } + } +} diff --git a/CentralConfigGenerator.Core/Analysis/Rules/FloatingVersionRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/FloatingVersionRule.cs new file mode 100644 index 0000000..71f5c2b --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/Rules/FloatingVersionRule.cs @@ -0,0 +1,66 @@ +using CentralConfigGenerator.Core.Services; + +namespace CentralConfigGenerator.Core.Analysis.Rules; + +/// Flags wildcard and open-ended version specifications. +public sealed class FloatingVersionRule : IAnalysisRule +{ + public string RuleId => RuleIds.FloatingVersion; + + public string Title => "Floating version"; + + public string Description => + "A version uses a wildcard (4.*) or an unbounded range ([4.0.0,))."; + + public string Rationale => + "Floating versions make builds non-reproducible: the same commit resolves to different " + + "binaries depending on when it was restored, which turns a feed publish into a surprise " + + "production change."; + + public Severity DefaultSeverity => Severity.Moderate; + + public IEnumerable Analyze(AnalysisContext context) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var (packageId, version) in AllVersions(context)) + { + if (!VersionSelector.IsFloating(version) || !seen.Add($"{packageId}|{version}")) + { + continue; + } + + yield return new Finding + { + RuleId = RuleId, + Severity = DefaultSeverity, + PackageId = packageId, + Version = version, + Message = $"'{packageId}' uses the floating version '{version}'.", + Recommendation = + "Pin an exact version so restores are reproducible across machines and time.", + }; + } + } + + private static IEnumerable<(string PackageId, string Version)> AllVersions( + AnalysisContext context + ) + { + foreach (var reference in context.RelevantReferences) + { + if (!string.IsNullOrWhiteSpace(reference.Version)) + { + yield return (reference.PackageId, reference.Version!); + } + } + + foreach (var (packageId, version) in context.CentralVersions) + { + if (!context.IsIgnored(packageId)) + { + yield return (packageId, version); + } + } + } +} diff --git a/CentralConfigGenerator.Core/Analysis/Rules/FrameworkAlignmentRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/FrameworkAlignmentRule.cs new file mode 100644 index 0000000..121ebee --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/Rules/FrameworkAlignmentRule.cs @@ -0,0 +1,68 @@ +namespace CentralConfigGenerator.Core.Analysis.Rules; + +/// Reports target-framework drift across the workspace. +public sealed class FrameworkAlignmentRule : IAnalysisRule +{ + public string RuleId => RuleIds.FrameworkAlignment; + + public string Title => "Target framework drift"; + + public string Description => + "Projects in the same workspace target different framework versions."; + + public string Rationale => + "Mixed target frameworks split the dependency graph: each TFM resolves packages " + + "independently, so a single central version can still produce two different builds."; + + public Severity DefaultSeverity => Severity.Low; + + public IEnumerable Analyze(AnalysisContext context) + { + if (context.TargetFrameworks.Count == 0) + { + yield break; + } + + var distinct = context + .TargetFrameworks.SelectMany(pair => pair.Value) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (distinct.Count < 2) + { + yield break; + } + + // The dominant framework is the one the most projects can build against. + var dominant = distinct + .OrderByDescending(tfm => + context.TargetFrameworks.Count(pair => + pair.Value.Contains(tfm, StringComparer.OrdinalIgnoreCase) + ) + ) + .ThenByDescending(tfm => tfm, StringComparer.OrdinalIgnoreCase) + .First(); + + foreach (var (projectPath, frameworks) in context.TargetFrameworks) + { + // A multi-targeted project that includes the dominant framework is aligned already. + if (frameworks.Contains(dominant, StringComparer.OrdinalIgnoreCase)) + { + continue; + } + + yield return new Finding + { + RuleId = RuleId, + Severity = DefaultSeverity, + ProjectPath = projectPath, + Subject = string.Join(";", frameworks), + Message = + $"{Path.GetFileName(projectPath)} targets {string.Join(", ", frameworks)} " + + $"but not {dominant}, which the rest of the workspace builds against.", + Recommendation = + $"Add {dominant} to its target frameworks, or hoist TargetFramework into Directory.Build.props deliberately.", + }; + } + } +} diff --git a/CentralConfigGenerator.Core/Analysis/Rules/InlineVersionUnderCpmRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/InlineVersionUnderCpmRule.cs new file mode 100644 index 0000000..1a09151 --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/Rules/InlineVersionUnderCpmRule.cs @@ -0,0 +1,55 @@ +namespace CentralConfigGenerator.Core.Analysis.Rules; + +/// Flags inline versions that survive after central package management was enabled. +public sealed class InlineVersionUnderCpmRule : IAnalysisRule +{ + public string RuleId => RuleIds.InlineVersionUnderCpm; + + public string Title => "Inline version under central package management"; + + public string Description => + "A project still declares Version on a PackageReference even though " + + "ManagePackageVersionsCentrally is enabled."; + + public string Rationale => + "NuGet raises NU1008 for these references, and where it does not, the inline version " + + "silently wins over the central one — exactly the drift CPM is meant to prevent."; + + public Severity DefaultSeverity => Severity.High; + + public bool RequiresCentralPackageManagement => true; + + public IEnumerable Analyze(AnalysisContext context) + { + foreach (var reference in context.RelevantReferences) + { + if (reference.IsGlobal || string.IsNullOrWhiteSpace(reference.Version)) + { + continue; + } + + var central = context.CentralVersions.TryGetValue(reference.PackageId, out var value) + ? value + : null; + + var drift = + central is not null + && !string.Equals(central, reference.Version, StringComparison.OrdinalIgnoreCase); + + yield return new Finding + { + RuleId = RuleId, + Severity = drift ? Severity.High : Severity.Moderate, + PackageId = reference.PackageId, + Version = reference.Version, + ProjectPath = reference.ProjectPath, + Message = drift + ? $"'{reference.PackageId}' pins {reference.Version} inline while Directory.Packages.props says {central}." + : $"'{reference.PackageId}' still declares an inline version of {reference.Version}.", + Recommendation = "Remove the Version attribute and let the central entry apply.", + Fix = FixKind.RemoveInlineVersion, + FixValue = central ?? reference.Version, + }; + } + } +} diff --git a/CentralConfigGenerator.Core/Analysis/Rules/LicenseRiskRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/LicenseRiskRule.cs new file mode 100644 index 0000000..bbac379 --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/Rules/LicenseRiskRule.cs @@ -0,0 +1,106 @@ +namespace CentralConfigGenerator.Core.Analysis.Rules; + +/// Flags copyleft, proprietary and unknown licences. +public sealed class LicenseRiskRule : IAnalysisRule +{ + /// Licence identifiers that impose reciprocal obligations on distributed software. + private static readonly string[] StrongCopyleft = + [ + "GPL-2.0", + "GPL-3.0", + "AGPL-3.0", + "SSPL", + "OSL-3.0", + "EUPL", + ]; + + private static readonly string[] WeakCopyleft = ["LGPL", "MPL-2.0", "MS-RL", "CDDL", "EPL"]; + + public string RuleId => RuleIds.LicenseRisk; + + public string Title => "Licence risk"; + + public string Description => + "A dependency ships under a copyleft, proprietary or undeclared licence."; + + public string Rationale => + "Copyleft terms can propagate to the software that links the package, and an undeclared " + + "licence means no permission has actually been granted. Both need a human decision."; + + public Severity DefaultSeverity => Severity.Moderate; + + public bool RequiresNetwork => true; + + public bool IsEnabled(AnalysisOptions options) => options.Licenses; + + public IEnumerable Analyze(AnalysisContext context) + { + foreach (var (packageId, snapshot) in context.Metadata) + { + if (context.IsIgnored(packageId) || !snapshot.Found) + { + continue; + } + + var license = snapshot.License; + + if (string.IsNullOrWhiteSpace(license)) + { + yield return new Finding + { + RuleId = RuleId, + Severity = Severity.Low, + PackageId = packageId, + Version = context.EffectiveVersion(packageId), + Message = $"'{packageId}' does not declare a licence.", + Recommendation = + "Confirm the terms with the author before shipping this dependency.", + }; + continue; + } + + if (StrongCopyleft.Any(l => license.Contains(l, StringComparison.OrdinalIgnoreCase))) + { + yield return new Finding + { + RuleId = RuleId, + Severity = Severity.High, + PackageId = packageId, + Version = context.EffectiveVersion(packageId), + Message = $"'{packageId}' is licensed under {license} (strong copyleft).", + Recommendation = + "Distributing linked software may require releasing your source. Get legal sign-off.", + }; + continue; + } + + if (WeakCopyleft.Any(l => license.Contains(l, StringComparison.OrdinalIgnoreCase))) + { + yield return new Finding + { + RuleId = RuleId, + Severity = Severity.Moderate, + PackageId = packageId, + Version = context.EffectiveVersion(packageId), + Message = $"'{packageId}' is licensed under {license} (weak copyleft).", + Recommendation = + "Modifications to the package itself must usually be published. Keep it unforked.", + }; + continue; + } + + if (license.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + { + yield return new Finding + { + RuleId = RuleId, + Severity = Severity.Info, + PackageId = packageId, + Version = context.EffectiveVersion(packageId), + Message = $"'{packageId}' points at a licence URL rather than an SPDX expression.", + Recommendation = $"Review {license} manually.", + }; + } + } + } +} diff --git a/CentralConfigGenerator.Core/Analysis/Rules/MissingCentralVersionRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/MissingCentralVersionRule.cs new file mode 100644 index 0000000..6b9e45c --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/Rules/MissingCentralVersionRule.cs @@ -0,0 +1,54 @@ +namespace CentralConfigGenerator.Core.Analysis.Rules; + +/// Flags references that have neither an inline nor a central version. +public sealed class MissingCentralVersionRule : IAnalysisRule +{ + public string RuleId => RuleIds.MissingCentralVersion; + + public string Title => "Package has no version anywhere"; + + public string Description => + "A PackageReference has no inline version and no matching PackageVersion entry."; + + public string Rationale => + "The restore fails with NU1010. It usually means a package was added by hand after the " + + "migration, or a PackageVersion entry was deleted."; + + public Severity DefaultSeverity => Severity.Critical; + + public bool RequiresCentralPackageManagement => true; + + public IEnumerable Analyze(AnalysisContext context) + { + foreach (var reference in context.RelevantReferences) + { + if ( + reference.IsGlobal + || !string.IsNullOrWhiteSpace(reference.Version) + || context.CentralVersions.ContainsKey(reference.PackageId) + ) + { + continue; + } + + var suggested = context + .MetadataFor(reference.PackageId) + ?.LatestStable?.ToNormalizedString(); + + yield return new Finding + { + RuleId = RuleId, + Severity = DefaultSeverity, + PackageId = reference.PackageId, + ProjectPath = reference.ProjectPath, + Message = + $"'{reference.PackageId}' has no version: no inline attribute and no PackageVersion entry.", + Recommendation = suggested is null + ? "Add a PackageVersion entry to Directory.Packages.props." + : $"Add .", + Fix = suggested is null ? FixKind.None : FixKind.SetCentralVersion, + FixValue = suggested, + }; + } + } +} diff --git a/CentralConfigGenerator.Core/Analysis/Rules/OrphanedPackageVersionRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/OrphanedPackageVersionRule.cs new file mode 100644 index 0000000..c02bd3a --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/Rules/OrphanedPackageVersionRule.cs @@ -0,0 +1,48 @@ +namespace CentralConfigGenerator.Core.Analysis.Rules; + +/// Flags PackageVersion entries that no project references any more. +public sealed class OrphanedPackageVersionRule : IAnalysisRule +{ + public string RuleId => RuleIds.OrphanedPackageVersion; + + public string Title => "Unused central version entry"; + + public string Description => + "Directory.Packages.props pins a package that no project references."; + + public string Rationale => + "Dead entries accumulate after refactors and make the central file look like it " + + "describes dependencies that no longer exist. They also confuse audit tooling."; + + public Severity DefaultSeverity => Severity.Info; + + public bool RequiresCentralPackageManagement => true; + + public IEnumerable Analyze(AnalysisContext context) + { + var referenced = context + .References.Select(r => r.PackageId) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var (packageId, version) in context.CentralVersions) + { + if (referenced.Contains(packageId) || context.IsIgnored(packageId)) + { + continue; + } + + yield return new Finding + { + RuleId = RuleId, + Severity = DefaultSeverity, + PackageId = packageId, + Version = version, + ProjectPath = context.PackagesPropsPath, + Message = $"'{packageId}' is pinned centrally but referenced by no project.", + Recommendation = "Remove the PackageVersion entry.", + Fix = FixKind.RemoveOrphanedPackageVersion, + FixValue = packageId, + }; + } + } +} diff --git a/CentralConfigGenerator.Core/Analysis/Rules/OutdatedPackageRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/OutdatedPackageRule.cs new file mode 100644 index 0000000..127d08b --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/Rules/OutdatedPackageRule.cs @@ -0,0 +1,71 @@ +using NuGet.Versioning; + +namespace CentralConfigGenerator.Core.Analysis.Rules; + +/// Reports packages that lag behind the newest published version. +public sealed class OutdatedPackageRule : IAnalysisRule +{ + public string RuleId => RuleIds.OutdatedPackage; + + public string Title => "Outdated package"; + + public string Description => "A newer version of the package is published on the feed."; + + public string Rationale => + "Severity scales with the size of the gap: a patch behind is noise, a major behind is a " + + "migration that only gets harder to do later."; + + public Severity DefaultSeverity => Severity.Low; + + public bool RequiresNetwork => true; + + public bool IsEnabled(AnalysisOptions options) => options.Outdated; + + public IEnumerable Analyze(AnalysisContext context) + { + foreach (var (packageId, snapshot) in context.Metadata) + { + if (context.IsIgnored(packageId) || !snapshot.Found) + { + continue; + } + + var current = context.EffectiveVersion(packageId); + if (current is null || !NuGetVersion.TryParse(current, out var currentVersion)) + { + continue; + } + + var latest = context.Options.IncludePrerelease + ? snapshot.LatestIncludingPrerelease + : snapshot.LatestStable; + + if (latest is null || latest <= currentVersion) + { + continue; + } + + var majorGap = latest.Major - currentVersion.Major; + var severity = majorGap switch + { + >= 2 => Severity.Moderate, + 1 => Severity.Low, + _ => latest.Minor > currentVersion.Minor ? Severity.Low : Severity.Info, + }; + + yield return new Finding + { + RuleId = RuleId, + Severity = severity, + PackageId = packageId, + Version = current, + Message = + $"'{packageId}' is on {current}; {latest.ToNormalizedString()} is available" + + (majorGap > 0 ? $" ({majorGap} major version(s) behind)." : "."), + Recommendation = $"Update to {latest.ToNormalizedString()}.", + Fix = FixKind.SetCentralVersion, + FixValue = latest.ToNormalizedString(), + }; + } + } +} diff --git a/CentralConfigGenerator.Core/Analysis/Rules/PrereleaseInProductionRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/PrereleaseInProductionRule.cs new file mode 100644 index 0000000..d2e476c --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/Rules/PrereleaseInProductionRule.cs @@ -0,0 +1,57 @@ +using CentralConfigGenerator.Core.Services; + +namespace CentralConfigGenerator.Core.Analysis.Rules; + +/// Flags pre-release versions outside of test-only packages. +public sealed class PrereleaseInProductionRule : IAnalysisRule +{ + public string RuleId => RuleIds.PrereleaseInProduction; + + public string Title => "Pre-release dependency"; + + public string Description => "A package resolves to a pre-release version."; + + public string Rationale => + "Pre-release packages can be unlisted or replaced by the author without notice, and their " + + "API is not covered by any compatibility promise."; + + public Severity DefaultSeverity => Severity.Low; + + public bool IsEnabled(AnalysisOptions options) => !options.IncludePrerelease; + + public IEnumerable Analyze(AnalysisContext context) + { + var reported = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var reference in context.RelevantReferences) + { + var version = reference.Version ?? context.EffectiveVersion(reference.PackageId); + + if ( + version is null + || !VersionSelector.IsPrerelease(version) + || !reported.Add(reference.PackageId) + ) + { + continue; + } + + var stable = context.MetadataFor(reference.PackageId)?.LatestStable; + + yield return new Finding + { + RuleId = RuleId, + Severity = DefaultSeverity, + PackageId = reference.PackageId, + Version = version, + ProjectPath = reference.ProjectPath, + Message = $"'{reference.PackageId}' uses pre-release version {version}.", + Recommendation = stable is null + ? "Move to a stable release before shipping." + : $"A stable {stable.ToNormalizedString()} is available.", + Fix = stable is null ? FixKind.None : FixKind.SetCentralVersion, + FixValue = stable?.ToNormalizedString(), + }; + } + } +} diff --git a/CentralConfigGenerator.Core/Analysis/Rules/PropertyDriftRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/PropertyDriftRule.cs new file mode 100644 index 0000000..dc2d0f9 --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/Rules/PropertyDriftRule.cs @@ -0,0 +1,48 @@ +using CentralConfigGenerator.Core.Migration; + +namespace CentralConfigGenerator.Core.Analysis.Rules; + +/// Reports properties repeated identically in every project, which belong in Directory.Build.props. +public sealed class PropertyDriftRule : IAnalysisRule +{ + public string RuleId => RuleIds.PropertyDrift; + + public string Title => "Property duplicated across projects"; + + public string Description => + "Several projects declare the same MSBuild property with the same value."; + + public string Rationale => + "Copies drift. One project eventually gets edited and the difference is invisible in " + + "review. Directory.Build.props makes the value single-sourced."; + + public Severity DefaultSeverity => Severity.Info; + + public IEnumerable Analyze(AnalysisContext context) + { + if (context.Projects.Count < 2) + { + yield break; + } + + var hoistable = PropertyHoister.FindHoistableProperties( + context.Projects, + BuildPropertyDefaults.Recommended + ); + + foreach (var (name, value) in hoistable.OrderBy(p => p.Key, StringComparer.OrdinalIgnoreCase)) + { + yield return new Finding + { + RuleId = RuleId, + Severity = DefaultSeverity, + Subject = name, + Message = $"'{name}' is set to '{value}' in every project that declares it.", + // Hoisting also has to delete the property from every project, which is what the + // 'build' command does transactionally. Doing half of it here would be worse. + Recommendation = + $"Move {name} into Directory.Build.props (run 'central-config build' or 'migrate --unify-props').", + }; + } + } +} diff --git a/CentralConfigGenerator.Core/Analysis/Rules/RedundantReferenceRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/RedundantReferenceRule.cs new file mode 100644 index 0000000..2a24b32 --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/Rules/RedundantReferenceRule.cs @@ -0,0 +1,72 @@ +namespace CentralConfigGenerator.Core.Analysis.Rules; + +/// Flags the same package referenced twice inside one project. +public sealed class RedundantReferenceRule : IAnalysisRule +{ + public string RuleId => RuleIds.RedundantReference; + + public string Title => "Duplicate package reference"; + + public string Description => "One project declares the same PackageReference more than once."; + + public string Rationale => + "Duplicates trigger NU1504 warnings and make it ambiguous which metadata (PrivateAssets, " + + "IncludeAssets) actually applies."; + + public Severity DefaultSeverity => Severity.Moderate; + + public IEnumerable Analyze(AnalysisContext context) + { + var groups = context.RelevantReferences.GroupBy( + r => (r.ProjectPath, r.PackageId), + TupleComparer.Instance + ); + + foreach (var group in groups) + { + var entries = group.ToList(); + if (entries.Count < 2) + { + continue; + } + + // References separated by a condition are legitimate (per-TFM references). + if (entries.All(e => !string.IsNullOrWhiteSpace(e.Condition)) + && entries.Select(e => e.Condition).Distinct(StringComparer.OrdinalIgnoreCase).Count() == entries.Count) + { + continue; + } + + yield return new Finding + { + RuleId = RuleId, + Severity = DefaultSeverity, + PackageId = group.Key.PackageId, + ProjectPath = group.Key.ProjectPath, + Message = + $"'{group.Key.PackageId}' is referenced {entries.Count} times in {Path.GetFileName(group.Key.ProjectPath)}.", + Recommendation = "Keep a single PackageReference and delete the duplicates.", + Fix = FixKind.RemoveDuplicateReference, + FixValue = group.Key.PackageId, + }; + } + } + + private sealed class TupleComparer : IEqualityComparer<(string ProjectPath, string PackageId)> + { + public static readonly TupleComparer Instance = new(); + + public bool Equals( + (string ProjectPath, string PackageId) x, + (string ProjectPath, string PackageId) y + ) => + string.Equals(x.ProjectPath, y.ProjectPath, StringComparison.OrdinalIgnoreCase) + && string.Equals(x.PackageId, y.PackageId, StringComparison.OrdinalIgnoreCase); + + public int GetHashCode((string ProjectPath, string PackageId) obj) => + HashCode.Combine( + obj.ProjectPath.ToLowerInvariant(), + obj.PackageId.ToLowerInvariant() + ); + } +} diff --git a/CentralConfigGenerator.Core/Analysis/Rules/SecurityVulnerabilityRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/SecurityVulnerabilityRule.cs new file mode 100644 index 0000000..d503dfb --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/Rules/SecurityVulnerabilityRule.cs @@ -0,0 +1,90 @@ +using NuGet.Versioning; + +namespace CentralConfigGenerator.Core.Analysis.Rules; + +/// Reports known security advisories affecting the resolved versions. +public sealed class SecurityVulnerabilityRule : IAnalysisRule +{ + public string RuleId => RuleIds.SecurityVulnerability; + + public string Title => "Known security vulnerability"; + + public string Description => + "The resolved version of a package is covered by a published security advisory."; + + public string Rationale => + "A vulnerable dependency is exploitable in production regardless of how the rest of the " + + "code is written. This is the one finding that should always block a release."; + + public Severity DefaultSeverity => Severity.Critical; + + public bool RequiresNetwork => true; + + public bool IsEnabled(AnalysisOptions options) => options.Audit; + + public IEnumerable Analyze(AnalysisContext context) + { + foreach (var (packageId, snapshot) in context.Metadata) + { + if (context.IsIgnored(packageId) || snapshot.Vulnerabilities.Count == 0) + { + continue; + } + + var current = context.EffectiveVersion(packageId); + if (current is null || !NuGetVersion.TryParse(current, out var currentVersion)) + { + continue; + } + + var affecting = snapshot + .Vulnerabilities.Where(v => + v.AffectedRange is not null + && NuGetVersion.TryParse(v.AffectedRange, out var affected) + && affected.Equals(currentVersion) + ) + .ToList(); + + if (affecting.Count == 0) + { + continue; + } + + var worst = affecting.Max(v => v.Severity); + var fixedVersion = FindFirstSafeVersion(snapshot, currentVersion); + + yield return new Finding + { + RuleId = RuleId, + Severity = worst, + PackageId = packageId, + Version = current, + Message = + $"'{packageId}' {current} has {affecting.Count} known advisory/advisories ({worst}).", + Recommendation = fixedVersion is null + ? $"Review {affecting[0].AdvisoryUrl}" + : $"Upgrade to {fixedVersion.ToNormalizedString()}. See {affecting[0].AdvisoryUrl}", + Fix = fixedVersion is null ? FixKind.None : FixKind.SetCentralVersion, + FixValue = fixedVersion?.ToNormalizedString(), + }; + } + } + + /// Finds the lowest version above the current one that carries no advisory. + private static NuGetVersion? FindFirstSafeVersion( + NuGet.PackageMetadataSnapshot snapshot, + NuGetVersion current + ) + { + var vulnerable = snapshot + .Vulnerabilities.Select(v => v.AffectedRange) + .Where(v => v is not null) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + return snapshot + .AllVersions.Where(v => v > current && !v.IsPrerelease) + .Where(v => !vulnerable.Contains(v.ToNormalizedString())) + .OrderBy(v => v) + .FirstOrDefault(); + } +} diff --git a/CentralConfigGenerator.Core/Analysis/Rules/TransitiveConflictRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/TransitiveConflictRule.cs new file mode 100644 index 0000000..64f18b4 --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/Rules/TransitiveConflictRule.cs @@ -0,0 +1,62 @@ +namespace CentralConfigGenerator.Core.Analysis.Rules; + +/// Reports packages whose transitive graph resolves to more than one version. +public sealed class TransitiveConflictRule : IAnalysisRule +{ + public string RuleId => RuleIds.TransitiveConflict; + + public string Title => "Divergent transitive resolution"; + + public string Description => + "The same package resolves to different versions in different projects or frameworks."; + + public string Rationale => + "Divergence is invisible in the project files but very visible at runtime, where one " + + "assembly loads and the other does not. Pinning centrally collapses the graph."; + + public Severity DefaultSeverity => Severity.Moderate; + + public bool IsEnabled(AnalysisOptions options) => options.Transitive; + + public IEnumerable Analyze(AnalysisContext context) + { + if (!context.Graph.IsAvailable) + { + yield break; + } + + foreach (var group in context.Graph.DivergentPackages) + { + if (context.IsIgnored(group.Key)) + { + continue; + } + + var versions = group + .Select(p => p.ResolvedVersion) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(v => v, StringComparer.OrdinalIgnoreCase) + .ToList(); + + var highest = versions[^1]; + var isDirect = group.Any(p => !p.IsTransitive); + + yield return new Finding + { + RuleId = RuleId, + Severity = isDirect ? Severity.Moderate : Severity.Low, + PackageId = group.Key, + Version = highest, + Message = + $"'{group.Key}' resolves to {versions.Count} versions across the graph: {string.Join(", ", versions)}.", + Recommendation = + $"Pin '{group.Key}' to {highest} centrally" + + (context.TransitivePinningEnabled + ? "." + : " and enable CentralPackageTransitivePinningEnabled."), + Fix = FixKind.SetCentralVersion, + FixValue = highest, + }; + } + } +} diff --git a/CentralConfigGenerator.Core/Analysis/Rules/UnpinnedTransitiveDependencyRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/UnpinnedTransitiveDependencyRule.cs new file mode 100644 index 0000000..26a22fe --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/Rules/UnpinnedTransitiveDependencyRule.cs @@ -0,0 +1,101 @@ +namespace CentralConfigGenerator.Core.Analysis.Rules; + +/// +/// Reports vulnerable or deprecated transitive dependencies that no central entry pins, +/// so they cannot be fixed without upgrading their parent. +/// +public sealed class UnpinnedTransitiveDependencyRule : IAnalysisRule +{ + public string RuleId => RuleIds.UnpinnedTransitiveDependency; + + public string Title => "Risky transitive dependency is not pinned"; + + public string Description => + "A transitive dependency carries an advisory or deprecation notice but has no " + + "PackageVersion entry that could override it."; + + public string Rationale => + "Transitive pinning is the only way to patch a vulnerable indirect dependency without " + + "waiting for the intermediate package to publish a fix."; + + public Severity DefaultSeverity => Severity.High; + + public bool RequiresNetwork => true; + + public bool IsEnabled(AnalysisOptions options) => options.Transitive && options.Audit; + + public IEnumerable Analyze(AnalysisContext context) + { + if (!context.Graph.IsAvailable) + { + yield break; + } + + var directIds = context + .References.Select(r => r.PackageId) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var package in context.Graph.Transitive) + { + if ( + directIds.Contains(package.PackageId) + || context.CentralVersions.ContainsKey(package.PackageId) + || context.IsIgnored(package.PackageId) + || !seen.Add(package.PackageId) + ) + { + continue; + } + + var snapshot = context.MetadataFor(package.PackageId); + if (snapshot is null) + { + continue; + } + + var affected = snapshot.Vulnerabilities.Any(v => + string.Equals(v.AffectedRange, package.ResolvedVersion, StringComparison.OrdinalIgnoreCase) + ); + + if (!affected && !snapshot.IsDeprecated) + { + continue; + } + + var safe = snapshot + .AllVersions.Where(v => + !v.IsPrerelease + && v.ToNormalizedString() != package.ResolvedVersion + && !snapshot.Vulnerabilities.Any(x => + string.Equals( + x.AffectedRange, + v.ToNormalizedString(), + StringComparison.OrdinalIgnoreCase + ) + ) + ) + .OrderByDescending(v => v) + .FirstOrDefault(); + + yield return new Finding + { + RuleId = RuleId, + Severity = affected ? Severity.High : Severity.Moderate, + PackageId = package.PackageId, + Version = package.ResolvedVersion, + ProjectPath = package.ProjectPath, + Message = + $"Transitive '{package.PackageId}' {package.ResolvedVersion} is " + + (affected ? "vulnerable" : "deprecated") + + " and is not pinned centrally.", + Recommendation = safe is null + ? "Enable CentralPackageTransitivePinningEnabled and pin a safe version." + : $"Pin '{package.PackageId}' to {safe.ToNormalizedString()} with transitive pinning enabled.", + Fix = safe is null ? FixKind.None : FixKind.SetCentralVersion, + FixValue = safe?.ToNormalizedString(), + }; + } + } +} diff --git a/CentralConfigGenerator.Core/Analysis/Rules/VersionInconsistencyRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/VersionInconsistencyRule.cs new file mode 100644 index 0000000..2c08ab4 --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/Rules/VersionInconsistencyRule.cs @@ -0,0 +1,60 @@ +using CentralConfigGenerator.Core.Services; + +namespace CentralConfigGenerator.Core.Analysis.Rules; + +/// Flags packages referenced with more than one version across the workspace. +public sealed class VersionInconsistencyRule : IAnalysisRule +{ + public string RuleId => RuleIds.VersionInconsistency; + + public string Title => "Inconsistent package versions"; + + public string Description => + "The same package is referenced with different versions by different projects."; + + public string Rationale => + "Divergent versions produce different binaries per project and make runtime binding " + + "failures likely. Central package management removes the drift by pinning one version."; + + public Severity DefaultSeverity => Severity.Moderate; + + public IEnumerable Analyze(AnalysisContext context) + { + var groups = context + .RelevantReferences.Where(r => !string.IsNullOrWhiteSpace(r.Version)) + .GroupBy(r => r.PackageId, StringComparer.OrdinalIgnoreCase); + + foreach (var group in groups) + { + var versions = group + .Select(r => r.Version!) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(v => v, StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (versions.Count < 2) + { + continue; + } + + var highest = versions + .OrderBy(v => v, Comparer.Create((a, b) => + VersionSelector.Compare(a, b, global::NuGet.Versioning.VersionComparison.VersionRelease))) + .Last(); + + yield return new Finding + { + RuleId = RuleId, + Severity = DefaultSeverity, + PackageId = group.Key, + Message = + $"'{group.Key}' is referenced with {versions.Count} versions: {string.Join(", ", versions)}.", + Recommendation = + $"Pin a single version (highest observed is {highest}) in Directory.Packages.props.", + Fix = FixKind.SetCentralVersion, + FixValue = highest, + Version = highest, + }; + } + } +} diff --git a/CentralConfigGenerator.Core/Analysis/Severity.cs b/CentralConfigGenerator.Core/Analysis/Severity.cs new file mode 100644 index 0000000..4a19cfa --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/Severity.cs @@ -0,0 +1,13 @@ +namespace CentralConfigGenerator.Core.Analysis; + +/// Finding severity, ordered so thresholds can be compared numerically. +public enum Severity +{ + /// Used only as a --fail-on threshold meaning "never fail". + Never = 0, + Info = 1, + Low = 2, + Moderate = 3, + High = 4, + Critical = 5, +} diff --git a/CentralConfigGenerator.Core/CentralConfigGenerator.Core.csproj b/CentralConfigGenerator.Core/CentralConfigGenerator.Core.csproj index 9d48c53..8eacf32 100644 --- a/CentralConfigGenerator.Core/CentralConfigGenerator.Core.csproj +++ b/CentralConfigGenerator.Core/CentralConfigGenerator.Core.csproj @@ -1,5 +1,12 @@ - - + + + net8.0;net9.0;net10.0 + + + + + + diff --git a/CentralConfigGenerator.Core/Configuration/CentralConfigSettings.cs b/CentralConfigGenerator.Core/Configuration/CentralConfigSettings.cs new file mode 100644 index 0000000..9d82c4e --- /dev/null +++ b/CentralConfigGenerator.Core/Configuration/CentralConfigSettings.cs @@ -0,0 +1,67 @@ +using System.Text.Json.Serialization; +using CentralConfigGenerator.Core.Analysis; +using CentralConfigGenerator.Core.Models; + +namespace CentralConfigGenerator.Core.Configuration; + +/// +/// Contents of a .centralconfig.json file. Every value is optional; anything left unset +/// falls back to the command-line default. +/// +public sealed record CentralConfigSettings +{ + [JsonPropertyName("$schema")] + public string? Schema { get; init; } + + public VersionResolutionStrategyName? ConflictStrategy { get; init; } + + public bool? Backup { get; init; } + + public string? BackupDir { get; init; } + + public bool? AddGitignore { get; init; } + + public Severity? FailOn { get; init; } + + public string? Baseline { get; init; } + + public RetentionSettings? Retention { get; init; } + + public Dictionary? Rules { get; init; } + + public string? ExcludeDirs { get; init; } + + public bool? TransitivePinning { get; init; } + + public bool? IgnorePrerelease { get; init; } + + public LineEndingStyle? LineEnding { get; init; } + + public string? Encoding { get; init; } + + /// Property names promoted into Directory.Build.props by the build command. + public List? BuildProperties { get; init; } + + /// Packages that analysis should ignore entirely. + public List? IgnorePackages { get; init; } + + public string? SourceDirectory { get; init; } +} + +public sealed record RetentionSettings +{ + public bool? Enabled { get; init; } + + public int? MaxBackups { get; init; } +} + +/// Serialisable mirror of VersionResolutionStrategy, including Fail. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum VersionResolutionStrategyName +{ + Highest, + Lowest, + MostCommon, + Fail, + Manual, +} diff --git a/CentralConfigGenerator.Core/Configuration/ConfigurationLoader.cs b/CentralConfigGenerator.Core/Configuration/ConfigurationLoader.cs new file mode 100644 index 0000000..22a8abd --- /dev/null +++ b/CentralConfigGenerator.Core/Configuration/ConfigurationLoader.cs @@ -0,0 +1,119 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using CentralConfigGenerator.Core.IO; + +namespace CentralConfigGenerator.Core.Configuration; + +/// +/// Discovers and reads .centralconfig.json by walking up from a starting directory, +/// mirroring how .editorconfig and global.json are resolved. +/// +public sealed class ConfigurationLoader(IFileSystem fileSystem) +{ + public const string FileName = ".centralconfig.json"; + + public const string SchemaUrl = + "https://raw.githubusercontent.com/TarasKovalenko/CentralConfigGenerator/main/schemas/centralconfig.schema.json"; + + public static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter() }, + }; + + public string? Find(string startDirectory) + { + var directory = new DirectoryInfo(Path.GetFullPath(startDirectory)); + + while (directory is not null) + { + var candidate = Path.Combine(directory.FullName, FileName); + if (fileSystem.FileExists(candidate)) + { + return candidate; + } + + directory = directory.Parent; + } + + return null; + } + + public async Task<(CentralConfigSettings? Settings, string? Path, string? Error)> LoadAsync( + string startDirectory, + CancellationToken cancellationToken = default + ) + { + var path = Find(startDirectory); + if (path is null) + { + return (null, null, null); + } + + try + { + var json = await fileSystem.ReadAllTextAsync(path, cancellationToken); + var settings = JsonSerializer.Deserialize( + json, + SerializerOptions + ); + return (settings, path, null); + } + catch (JsonException ex) + { + return (null, path, ex.Message); + } + } + + public async Task ScaffoldAsync( + string directory, + bool overwrite, + CancellationToken cancellationToken = default + ) + { + var path = Path.Combine(Path.GetFullPath(directory), FileName); + + if (fileSystem.FileExists(path) && !overwrite) + { + return path; + } + + var template = new CentralConfigSettings + { + Schema = SchemaUrl, + ConflictStrategy = VersionResolutionStrategyName.Highest, + Backup = true, + AddGitignore = true, + FailOn = Analysis.Severity.High, + TransitivePinning = false, + IgnorePrerelease = false, + Retention = new RetentionSettings { Enabled = true, MaxBackups = 5 }, + Rules = new Dictionary + { + [Analysis.RuleIds.OutdatedPackage] = "Low", + [Analysis.RuleIds.LicenseRisk] = "Moderate", + }, + BuildProperties = + [ + "TargetFramework", + "TargetFrameworks", + "ImplicitUsings", + "Nullable", + "LangVersion", + ], + }; + + await fileSystem.WriteAllTextAsync( + path, + JsonSerializer.Serialize(template, SerializerOptions), + cancellationToken + ); + + return path; + } +} diff --git a/CentralConfigGenerator.Core/Discovery/DiscoveryOptions.cs b/CentralConfigGenerator.Core/Discovery/DiscoveryOptions.cs new file mode 100644 index 0000000..d320132 --- /dev/null +++ b/CentralConfigGenerator.Core/Discovery/DiscoveryOptions.cs @@ -0,0 +1,52 @@ +using System.Text.RegularExpressions; +using CentralConfigGenerator.Core.Models; + +namespace CentralConfigGenerator.Core.Discovery; + +/// +/// Controls which MSBuild files a scan picks up. +/// +public sealed record DiscoveryOptions +{ + public static readonly string[] DefaultExcludedDirectories = + [ + "bin", + "obj", + ".git", + ".vs", + ".idea", + "node_modules", + "packages", + "artifacts", + "TestResults", + // Our own rollback copies are full project trees; scanning them would double-count + // every project and let a fix rewrite the backup it is supposed to restore from. + ".centralconfig-backups", + ]; + + /// Root directory to scan. Ignored when is set. + public string RootDirectory { get; init; } = Directory.GetCurrentDirectory(); + + /// Optional solution (.sln/.slnx) or solution filter (.slnf) that scopes the scan. + public string? SolutionPath { get; init; } + + /// Optional single project path that scopes the scan. + public string? ProjectPath { get; init; } + + /// Regex applied to directory names and relative paths; matches are skipped. + public string? ExcludePattern { get; init; } + + /// Project kinds to include. Defaults to C#, F# and VB. + public IReadOnlyCollection IncludedKinds { get; init; } = + [ProjectKind.CSharp, ProjectKind.FSharp, ProjectKind.VisualBasic]; + + public bool Recursive { get; init; } = true; + + /// When false, the built-in bin/obj/node_modules exclusions are not applied. + public bool ApplyDefaultExclusions { get; init; } = true; + + public Regex? CompileExcludeRegex() => + string.IsNullOrWhiteSpace(ExcludePattern) + ? null + : new Regex(ExcludePattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); +} diff --git a/CentralConfigGenerator.Core/Discovery/IProjectDiscoveryService.cs b/CentralConfigGenerator.Core/Discovery/IProjectDiscoveryService.cs new file mode 100644 index 0000000..f01ed0a --- /dev/null +++ b/CentralConfigGenerator.Core/Discovery/IProjectDiscoveryService.cs @@ -0,0 +1,15 @@ +using CentralConfigGenerator.Core.Models; + +namespace CentralConfigGenerator.Core.Discovery; + +public interface IProjectDiscoveryService +{ + Task> DiscoverAsync( + DiscoveryOptions options, + CancellationToken cancellationToken = default + ); + + Task LoadAsync(string path, CancellationToken cancellationToken = default); + + Task SaveAsync(ProjectFile projectFile, CancellationToken cancellationToken = default); +} diff --git a/CentralConfigGenerator.Core/Discovery/ProjectDiscoveryService.cs b/CentralConfigGenerator.Core/Discovery/ProjectDiscoveryService.cs new file mode 100644 index 0000000..b47aa6f --- /dev/null +++ b/CentralConfigGenerator.Core/Discovery/ProjectDiscoveryService.cs @@ -0,0 +1,183 @@ +using CentralConfigGenerator.Core.IO; +using CentralConfigGenerator.Core.Models; + +namespace CentralConfigGenerator.Core.Discovery; + +/// +/// Finds project files by scanning a directory tree or by walking a solution, preserving each +/// file's original encoding and line endings so they can be rewritten faithfully. +/// +public sealed class ProjectDiscoveryService(IFileSystem fileSystem) : IProjectDiscoveryService +{ + public async Task> DiscoverAsync( + DiscoveryOptions options, + CancellationToken cancellationToken = default + ) + { + var paths = ResolvePaths(options); + var results = new List(paths.Count); + + foreach (var path in paths) + { + cancellationToken.ThrowIfCancellationRequested(); + + var file = await LoadAsync(path, cancellationToken); + if (file is not null) + { + results.Add(file); + } + } + + return results + .OrderBy(f => f.Path, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + public async Task LoadAsync( + string path, + CancellationToken cancellationToken = default + ) + { + if (!fileSystem.FileExists(path)) + { + return null; + } + + var bytes = await fileSystem.ReadAllBytesAsync(path, cancellationToken); + var (content, format) = EncodingDetector.Decode(bytes); + + return new ProjectFile + { + Path = path, + Content = content, + Kind = ProjectKindExtensions.FromPath(path), + Format = format, + }; + } + + public async Task SaveAsync( + ProjectFile projectFile, + CancellationToken cancellationToken = default + ) + { + var bytes = EncodingDetector.Encode(projectFile.Content, projectFile.Format); + await fileSystem.WriteAllBytesAsync(projectFile.Path, bytes, cancellationToken); + } + + private IReadOnlyList ResolvePaths(DiscoveryOptions options) + { + var kinds = options.IncludedKinds.ToHashSet(); + + if (!string.IsNullOrWhiteSpace(options.ProjectPath)) + { + var project = Path.GetFullPath(options.ProjectPath); + if (fileSystem.FileExists(project)) + { + return [project]; + } + + if (fileSystem.DirectoryExists(project)) + { + return ScanDirectory(project, options, kinds); + } + + return []; + } + + if (!string.IsNullOrWhiteSpace(options.SolutionPath)) + { + var solution = Path.GetFullPath(options.SolutionPath); + if (fileSystem.FileExists(solution) && SolutionReader.IsSolution(solution)) + { + var content = fileSystem.ReadAllTextAsync(solution).GetAwaiter().GetResult(); + return SolutionReader + .ReadProjects(solution, content) + .Where(fileSystem.FileExists) + .Where(p => kinds.Contains(ProjectKindExtensions.FromPath(p))) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + if (fileSystem.DirectoryExists(solution)) + { + return ScanDirectory(solution, options, kinds); + } + + return []; + } + + return ScanDirectory(Path.GetFullPath(options.RootDirectory), options, kinds); + } + + private IReadOnlyList ScanDirectory( + string root, + DiscoveryOptions options, + HashSet kinds + ) + { + if (!fileSystem.DirectoryExists(root)) + { + return []; + } + + var excludeRegex = options.CompileExcludeRegex(); + var results = new List(); + + foreach (var path in fileSystem.EnumerateFiles(root, "*", options.Recursive)) + { + var kind = ProjectKindExtensions.FromPath(path); + if (!kinds.Contains(kind)) + { + continue; + } + + if (IsExcluded(root, path, options, excludeRegex)) + { + continue; + } + + results.Add(path); + } + + return results; + } + + internal static bool IsExcluded( + string root, + string path, + DiscoveryOptions options, + System.Text.RegularExpressions.Regex? excludeRegex + ) + { + var relative = Path.GetRelativePath(root, path); + var segments = relative.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries + ); + + if (options.ApplyDefaultExclusions) + { + // The final segment is the file name itself and must not be matched against directory names. + for (var i = 0; i < segments.Length - 1; i++) + { + if ( + DiscoveryOptions.DefaultExcludedDirectories.Contains( + segments[i], + StringComparer.OrdinalIgnoreCase + ) + ) + { + return true; + } + } + } + + if (excludeRegex is null) + { + return false; + } + + return excludeRegex.IsMatch(relative.Replace(Path.DirectorySeparatorChar, '/')) + || segments.Any(excludeRegex.IsMatch); + } +} diff --git a/CentralConfigGenerator.Core/Discovery/SolutionReader.cs b/CentralConfigGenerator.Core/Discovery/SolutionReader.cs new file mode 100644 index 0000000..2f2dfb3 --- /dev/null +++ b/CentralConfigGenerator.Core/Discovery/SolutionReader.cs @@ -0,0 +1,130 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Xml.Linq; + +namespace CentralConfigGenerator.Core.Discovery; + +/// +/// Reads project paths out of .sln, .slnx and .slnf files. +/// +public static partial class SolutionReader +{ + public static bool IsSolution(string path) => + Path.GetExtension(path).ToLowerInvariant() is ".sln" or ".slnx" or ".slnf"; + + public static IReadOnlyList ReadProjects(string solutionPath, string content) + { + return Path.GetExtension(solutionPath).ToLowerInvariant() switch + { + ".slnx" => ReadSlnx(solutionPath, content), + ".slnf" => ReadSlnf(solutionPath, content), + _ => ReadSln(solutionPath, content), + }; + } + + private static IReadOnlyList ReadSln(string solutionPath, string content) + { + var root = Path.GetDirectoryName(Path.GetFullPath(solutionPath)) ?? "."; + var results = new List(); + + foreach (Match match in ProjectLineRegex().Matches(content)) + { + var relative = match.Groups["path"].Value.Trim(); + if (relative.Length == 0 || !HasProjectExtension(relative)) + { + continue; + } + + results.Add(Normalize(root, relative)); + } + + return results; + } + + private static IReadOnlyList ReadSlnx(string solutionPath, string content) + { + var root = Path.GetDirectoryName(Path.GetFullPath(solutionPath)) ?? "."; + var results = new List(); + + XDocument document; + try + { + document = XDocument.Parse(content); + } + catch (System.Xml.XmlException) + { + return results; + } + + foreach (var element in document.Descendants("Project")) + { + var relative = element.Attribute("Path")?.Value; + if (string.IsNullOrWhiteSpace(relative) || !HasProjectExtension(relative)) + { + continue; + } + + results.Add(Normalize(root, relative)); + } + + return results; + } + + private static IReadOnlyList ReadSlnf(string solutionPath, string content) + { + var root = Path.GetDirectoryName(Path.GetFullPath(solutionPath)) ?? "."; + var results = new List(); + + try + { + using var document = JsonDocument.Parse(content); + if (!document.RootElement.TryGetProperty("solution", out var solution)) + { + return results; + } + + if (solution.TryGetProperty("path", out var slnPath) && slnPath.GetString() is { } sln) + { + root = Path.GetDirectoryName(Path.GetFullPath(Normalize(root, sln))) ?? root; + } + + if (!solution.TryGetProperty("projects", out var projects)) + { + return results; + } + + foreach (var project in projects.EnumerateArray()) + { + if (project.GetString() is { } relative && HasProjectExtension(relative)) + { + results.Add(Normalize(root, relative)); + } + } + } + catch (JsonException) + { + // Malformed filter files simply yield no projects. + } + + return results; + } + + private static bool HasProjectExtension(string path) => + Path.GetExtension(path).ToLowerInvariant() is ".csproj" or ".fsproj" or ".vbproj"; + + private static string Normalize(string root, string relative) + { + var normalized = relative.Replace('\\', Path.DirectorySeparatorChar) + .Replace('/', Path.DirectorySeparatorChar); + + return Path.GetFullPath( + Path.IsPathRooted(normalized) ? normalized : Path.Combine(root, normalized) + ); + } + + [GeneratedRegex( + "^Project\\(\"\\{[^}]+\\}\"\\)\\s*=\\s*\"[^\"]*\"\\s*,\\s*\"(?[^\"]+)\"", + RegexOptions.Multiline | RegexOptions.CultureInvariant + )] + private static partial Regex ProjectLineRegex(); +} diff --git a/CentralConfigGenerator.Core/Generators/BuildPropsWriter.cs b/CentralConfigGenerator.Core/Generators/BuildPropsWriter.cs new file mode 100644 index 0000000..e2e99e2 --- /dev/null +++ b/CentralConfigGenerator.Core/Generators/BuildPropsWriter.cs @@ -0,0 +1,116 @@ +using System.Text; +using System.Xml.Linq; +using CentralConfigGenerator.Core.Xml; + +namespace CentralConfigGenerator.Core.Generators; + +/// Renders and merges Directory.Build.props from a set of hoisted properties. +public static class BuildPropsWriter +{ + public const string HeaderComment = + " Generated by CentralConfigGenerator. Properties shared by every project in this tree. "; + + public static string Create( + IReadOnlyDictionary properties, + IReadOnlyList? order = null, + bool includeHeaderComment = true, + string indent = " " + ) + { + var builder = new StringBuilder(); + builder.Append("\n"); + + if (includeHeaderComment) + { + builder.Append(indent).Append("\n"); + } + + builder.Append(indent).Append("\n"); + + foreach (var (name, value) in Order(properties, order)) + { + builder + .Append(indent) + .Append(indent) + .Append('<') + .Append(name) + .Append('>') + .Append(Escape(value)) + .Append("\n"); + } + + builder.Append(indent).Append("\n"); + builder.Append("\n"); + return builder.ToString(); + } + + public static string Merge( + string existingContent, + IReadOnlyDictionary properties, + IReadOnlyList? order = null, + string indent = " " + ) + { + if ( + string.IsNullOrWhiteSpace(existingContent) + || !MsBuildXml.TryParse(existingContent, out var document, out _) + || document.Root is null + ) + { + return Create(properties, order); + } + + var root = document.Root; + var ns = root.Name.Namespace; + + var propertyGroup = MsBuildXml.Elements(root, "PropertyGroup").FirstOrDefault(); + if (propertyGroup is null) + { + propertyGroup = new XElement(ns + "PropertyGroup"); + root.AddFirst(new XText("\n" + indent), propertyGroup); + } + + foreach (var (name, value) in Order(properties, order)) + { + var existing = MsBuildXml.Descendants(root, name).FirstOrDefault(); + if (existing is not null) + { + existing.Value = value; + continue; + } + + propertyGroup.Add(new XText("\n" + indent + indent), new XElement(ns + name, value)); + } + + if (propertyGroup.LastNode is XElement) + { + propertyGroup.Add(new XText("\n" + indent)); + } + + return MsBuildXml.ToString(document); + } + + private static IEnumerable> Order( + IReadOnlyDictionary properties, + IReadOnlyList? order + ) + { + if (order is null || order.Count == 0) + { + return properties.OrderBy(p => p.Key, StringComparer.OrdinalIgnoreCase); + } + + var rank = order + .Select((name, index) => (name, index)) + .ToDictionary(x => x.name, x => x.index, StringComparer.OrdinalIgnoreCase); + + return properties + .OrderBy(p => rank.TryGetValue(p.Key, out var index) ? index : int.MaxValue) + .ThenBy(p => p.Key, StringComparer.OrdinalIgnoreCase); + } + + private static string Escape(string value) => + value.Replace("&", "&").Replace("<", "<").Replace(">", ">"); +} diff --git a/CentralConfigGenerator.Core/Generators/PackagesPropsEditor.cs b/CentralConfigGenerator.Core/Generators/PackagesPropsEditor.cs new file mode 100644 index 0000000..07a3b3d --- /dev/null +++ b/CentralConfigGenerator.Core/Generators/PackagesPropsEditor.cs @@ -0,0 +1,171 @@ +using System.Xml.Linq; +using CentralConfigGenerator.Core.Xml; + +namespace CentralConfigGenerator.Core.Generators; + +/// Targeted edits to an existing Directory.Packages.props file. +public static class PackagesPropsEditor +{ + /// Sets (or adds) the version for one package. Returns null when nothing changed. + public static string? SetVersion(string content, string packageId, string version, string indent = " ") + { + if (!MsBuildXml.TryParse(content, out var document, out _) || document.Root is null) + { + return null; + } + + var element = FindEntry(document, packageId); + + if (element is not null) + { + var attribute = MsBuildXml.Attribute(element, "Version"); + if (attribute is not null) + { + if (string.Equals(attribute.Value, version, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + attribute.Value = version; + return MsBuildXml.ToString(document); + } + + var child = MsBuildXml.Elements(element, "Version").FirstOrDefault(); + if (child is not null) + { + if (string.Equals(child.Value.Trim(), version, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + child.Value = version; + } + else + { + element.SetAttributeValue("Version", version); + } + + return MsBuildXml.ToString(document); + } + + var root = document.Root; + var ns = root.Name.Namespace; + + var itemGroup = + MsBuildXml + .Elements(root, "ItemGroup") + .LastOrDefault(g => MsBuildXml.Elements(g, "PackageVersion").Any()); + + if (itemGroup is null) + { + itemGroup = new XElement(ns + "ItemGroup"); + root.Add(new XText("\n" + indent), itemGroup, new XText("\n")); + } + + itemGroup.Add( + new XText("\n" + indent + indent), + new XElement( + ns + "PackageVersion", + new XAttribute("Include", packageId), + new XAttribute("Version", version) + ) + ); + + if (itemGroup.LastNode is XElement) + { + itemGroup.Add(new XText("\n" + indent)); + } + + return MsBuildXml.ToString(document); + } + + /// Removes a package's central version entry. + public static string? RemoveEntry(string content, string packageId) + { + if (!MsBuildXml.TryParse(content, out var document, out _)) + { + return null; + } + + var element = FindEntry(document, packageId); + if (element is null) + { + return null; + } + + var parent = element.Parent; + MsBuildXml.RemoveWithWhitespace(element); + MsBuildXml.RemoveIfEmpty(parent); + + return MsBuildXml.ToString(document); + } + + /// Rewrites a package id so its casing matches the canonical spelling. + public static string? NormalizeCasing(string content, string canonicalId) + { + if (!MsBuildXml.TryParse(content, out var document, out _)) + { + return null; + } + + var changed = false; + + foreach (var element in MsBuildXml.Descendants(document, "PackageVersion")) + { + var attribute = MsBuildXml.Attribute(element, "Include"); + if ( + attribute is null + || !string.Equals(attribute.Value, canonicalId, StringComparison.OrdinalIgnoreCase) + || string.Equals(attribute.Value, canonicalId, StringComparison.Ordinal) + ) + { + continue; + } + + attribute.Value = canonicalId; + changed = true; + } + + return changed ? MsBuildXml.ToString(document) : null; + } + + /// Removes duplicate PackageVersion entries that differ only by casing. + public static string? DeduplicateEntries(string content) + { + if (!MsBuildXml.TryParse(content, out var document, out _)) + { + return null; + } + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var changed = false; + + foreach (var element in MsBuildXml.Descendants(document, "PackageVersion").ToList()) + { + var id = MsBuildXml.AttributeValue(element, "Include"); + if (string.IsNullOrWhiteSpace(id) || seen.Add(id)) + { + continue; + } + + var parent = element.Parent; + MsBuildXml.RemoveWithWhitespace(element); + MsBuildXml.RemoveIfEmpty(parent); + changed = true; + } + + return changed ? MsBuildXml.ToString(document) : null; + } + + private static XElement? FindEntry(XDocument document, string packageId) => + MsBuildXml + .Descendants(document, "PackageVersion") + .FirstOrDefault(e => + string.Equals( + MsBuildXml.AttributeValue(e, "Include") + ?? MsBuildXml.AttributeValue(e, "Update"), + packageId, + StringComparison.OrdinalIgnoreCase + ) + ); +} diff --git a/CentralConfigGenerator.Core/Generators/PackagesPropsWriter.cs b/CentralConfigGenerator.Core/Generators/PackagesPropsWriter.cs new file mode 100644 index 0000000..4cc1c18 --- /dev/null +++ b/CentralConfigGenerator.Core/Generators/PackagesPropsWriter.cs @@ -0,0 +1,258 @@ +using System.Text; +using System.Xml.Linq; +using CentralConfigGenerator.Core.Xml; + +namespace CentralConfigGenerator.Core.Generators; + +/// Options for rendering a Directory.Packages.props file. +public sealed record PackagesPropsOptions +{ + public bool TransitivePinning { get; init; } + + /// Packages emitted as GlobalPackageReference instead of PackageVersion. + public IReadOnlyCollection GlobalPackages { get; init; } = []; + + public bool IncludeHeaderComment { get; init; } = true; + + public string Indent { get; init; } = " "; +} + +/// +/// Renders and merges Directory.Packages.props. Merging preserves everything already in the file +/// — comments, conditions, item-group layout — and only touches the entries it must. +/// +public static class PackagesPropsWriter +{ + public const string HeaderComment = + " Generated by CentralConfigGenerator. Package versions are managed centrally. "; + + public static string Create( + IReadOnlyDictionary versions, + PackagesPropsOptions? options = null + ) + { + options ??= new PackagesPropsOptions(); + var globals = new HashSet(options.GlobalPackages, StringComparer.OrdinalIgnoreCase); + + var builder = new StringBuilder(); + builder.Append("\n"); + + if (options.IncludeHeaderComment) + { + builder.Append(options.Indent).Append("\n"); + } + + builder.Append(options.Indent).Append("\n"); + builder + .Append(options.Indent) + .Append(options.Indent) + .Append("true\n"); + + if (options.TransitivePinning) + { + builder + .Append(options.Indent) + .Append(options.Indent) + .Append( + "true\n" + ); + } + + builder.Append(options.Indent).Append("\n"); + + var ordered = versions + .OrderBy(p => p.Key, StringComparer.OrdinalIgnoreCase) + .ToList(); + + var globalEntries = ordered.Where(p => globals.Contains(p.Key)).ToList(); + var normalEntries = ordered.Where(p => !globals.Contains(p.Key)).ToList(); + + if (normalEntries.Count > 0) + { + builder.Append(options.Indent).Append("\n"); + foreach (var (id, version) in normalEntries) + { + builder + .Append(options.Indent) + .Append(options.Indent) + .Append("\n"); + } + + builder.Append(options.Indent).Append("\n"); + } + + if (globalEntries.Count > 0) + { + builder.Append(options.Indent).Append("\n"); + foreach (var (id, version) in globalEntries) + { + builder + .Append(options.Indent) + .Append(options.Indent) + .Append("\n"); + } + + builder.Append(options.Indent).Append("\n"); + } + + builder.Append("\n"); + return builder.ToString(); + } + + /// + /// Merges resolved versions into an existing props file, adding what is missing and updating + /// what changed, without reformatting untouched regions. + /// + public static string Merge( + string existingContent, + IReadOnlyDictionary versions, + PackagesPropsOptions? options = null + ) + { + options ??= new PackagesPropsOptions(); + + if ( + string.IsNullOrWhiteSpace(existingContent) + || !MsBuildXml.TryParse(existingContent, out var document, out _) + || document.Root is null + ) + { + return Create(versions, options); + } + + var root = document.Root; + var ns = root.Name.Namespace; + + EnsureProperty(root, ns, "ManagePackageVersionsCentrally", "true", options.Indent); + if (options.TransitivePinning) + { + EnsureProperty( + root, + ns, + "CentralPackageTransitivePinningEnabled", + "true", + options.Indent + ); + } + + var existing = MsBuildXml + .Descendants(document, "PackageVersion") + .ToDictionary( + e => + MsBuildXml.AttributeValue(e, "Include") + ?? MsBuildXml.AttributeValue(e, "Update") + ?? string.Empty, + e => e, + StringComparer.OrdinalIgnoreCase + ); + + var missing = new List>(); + + foreach (var pair in versions.OrderBy(p => p.Key, StringComparer.OrdinalIgnoreCase)) + { + if (!existing.TryGetValue(pair.Key, out var element)) + { + missing.Add(pair); + continue; + } + + var attribute = MsBuildXml.Attribute(element, "Version"); + if (attribute is not null) + { + if (!string.Equals(attribute.Value, pair.Value, StringComparison.OrdinalIgnoreCase)) + { + attribute.Value = pair.Value; + } + } + else + { + var child = MsBuildXml.Elements(element, "Version").FirstOrDefault(); + if (child is not null) + { + child.Value = pair.Value; + } + else + { + element.SetAttributeValue("Version", pair.Value); + } + } + } + + if (missing.Count > 0) + { + var itemGroup = MsBuildXml + .Elements(root, "ItemGroup") + .LastOrDefault(g => MsBuildXml.Elements(g, "PackageVersion").Any()); + + if (itemGroup is null) + { + itemGroup = new XElement(ns + "ItemGroup"); + root.Add(new XText("\n" + options.Indent), itemGroup, new XText("\n")); + } + + var indent = options.Indent + options.Indent; + + foreach (var (id, version) in missing) + { + var element = new XElement( + ns + "PackageVersion", + new XAttribute("Include", id), + new XAttribute("Version", version) + ); + + itemGroup.Add(new XText("\n" + indent), element); + } + + if (itemGroup.LastNode is not XText) + { + itemGroup.Add(new XText("\n" + options.Indent)); + } + } + + return MsBuildXml.ToString(document); + } + + private static void EnsureProperty( + XElement root, + XNamespace ns, + string name, + string value, + string indent + ) + { + var existing = MsBuildXml.Descendants(root, name).FirstOrDefault(); + if (existing is not null) + { + existing.Value = value; + return; + } + + var propertyGroup = MsBuildXml.Elements(root, "PropertyGroup").FirstOrDefault(); + if (propertyGroup is null) + { + propertyGroup = new XElement(ns + "PropertyGroup"); + root.AddFirst(new XText("\n" + indent), propertyGroup); + } + + propertyGroup.Add(new XText("\n" + indent + indent), new XElement(ns + name, value)); + if (propertyGroup.LastNode is not XText text || text.Value.Contains(name)) + { + propertyGroup.Add(new XText("\n" + indent)); + } + } + + private static string Escape(string value) => + value + .Replace("&", "&") + .Replace("<", "<") + .Replace(">", ">") + .Replace("\"", """); +} diff --git a/CentralConfigGenerator.Core/IO/EncodingDetector.cs b/CentralConfigGenerator.Core/IO/EncodingDetector.cs new file mode 100644 index 0000000..e05b36f --- /dev/null +++ b/CentralConfigGenerator.Core/IO/EncodingDetector.cs @@ -0,0 +1,103 @@ +using System.Text; +using CentralConfigGenerator.Core.Models; + +namespace CentralConfigGenerator.Core.IO; + +/// +/// Detects the encoding, BOM presence and line endings of a file from its raw bytes. +/// +public static class EncodingDetector +{ + public static (string Content, TextFileFormat Format) Decode(byte[] bytes) + { + var (encoding, bomLength) = DetectEncoding(bytes); + var content = encoding.GetString(bytes, bomLength, bytes.Length - bomLength); + + var format = new TextFileFormat + { + Encoding = encoding, + HasByteOrderMark = bomLength > 0, + LineEnding = LineEndingStyleExtensions.Detect(content), + EndsWithNewLine = content.EndsWith('\n') || content.EndsWith('\r'), + }; + + return (content, format); + } + + public static byte[] Encode(string content, TextFileFormat format) + { + var text = LineEndingStyleExtensions.Normalize(content, format.LineEnding); + var encoding = format.ToWriteEncoding(); + var preamble = format.HasByteOrderMark ? encoding.GetPreamble() : []; + var body = encoding.GetBytes(text); + + if (preamble.Length == 0) + { + return body; + } + + var result = new byte[preamble.Length + body.Length]; + preamble.CopyTo(result, 0); + body.CopyTo(result, preamble.Length); + return result; + } + + /// + /// Resolves an IANA web name (for example utf-8 or windows-1252) to an encoding. + /// + public static Encoding? Resolve(string? webName) + { + if (string.IsNullOrWhiteSpace(webName)) + { + return null; + } + + try + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + } + catch (InvalidOperationException) + { + // Provider already registered. + } + + try + { + return Encoding.GetEncoding(webName.Trim()); + } + catch (ArgumentException) + { + return null; + } + } + + private static (Encoding Encoding, int BomLength) DetectEncoding(byte[] bytes) + { + if (bytes.Length >= 4 && bytes[0] == 0xFF && bytes[1] == 0xFE && bytes[2] == 0 && bytes[3] == 0) + { + return (new UTF32Encoding(false, true), 4); + } + + if (bytes.Length >= 4 && bytes[0] == 0 && bytes[1] == 0 && bytes[2] == 0xFE && bytes[3] == 0xFF) + { + return (new UTF32Encoding(true, true), 4); + } + + if (bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF) + { + return (new UTF8Encoding(true), 3); + } + + if (bytes.Length >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE) + { + return (new UnicodeEncoding(false, true), 2); + } + + if (bytes.Length >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF) + { + return (new UnicodeEncoding(true, true), 2); + } + + return (new UTF8Encoding(false), 0); + } +} diff --git a/CentralConfigGenerator.Core/IO/IFileSystem.cs b/CentralConfigGenerator.Core/IO/IFileSystem.cs new file mode 100644 index 0000000..dc44d5b --- /dev/null +++ b/CentralConfigGenerator.Core/IO/IFileSystem.cs @@ -0,0 +1,114 @@ +using System.Text; + +namespace CentralConfigGenerator.Core.IO; + +/// +/// Thin file-system seam so every service in the core library stays testable. +/// +public interface IFileSystem +{ + bool FileExists(string path); + + bool DirectoryExists(string path); + + void CreateDirectory(string path); + + void DeleteFile(string path); + + void DeleteDirectory(string path, bool recursive); + + IReadOnlyList EnumerateFiles(string path, string searchPattern, bool recursive); + + IReadOnlyList EnumerateDirectories(string path); + + Task ReadAllBytesAsync(string path, CancellationToken cancellationToken = default); + + Task WriteAllBytesAsync(string path, byte[] bytes, CancellationToken cancellationToken = default); + + Task ReadAllTextAsync(string path, CancellationToken cancellationToken = default); + + Task WriteAllTextAsync(string path, string contents, CancellationToken cancellationToken = default); + + void CopyFile(string source, string destination, bool overwrite); + + DateTimeOffset GetLastWriteTime(string path); + + long GetFileSize(string path); +} + +/// Default backed by . +public sealed class PhysicalFileSystem : IFileSystem +{ + public bool FileExists(string path) => File.Exists(path); + + public bool DirectoryExists(string path) => Directory.Exists(path); + + public void CreateDirectory(string path) => Directory.CreateDirectory(path); + + public void DeleteFile(string path) => File.Delete(path); + + public void DeleteDirectory(string path, bool recursive) => Directory.Delete(path, recursive); + + public IReadOnlyList EnumerateFiles(string path, string searchPattern, bool recursive) => + Directory + .EnumerateFiles( + path, + searchPattern, + recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly + ) + .ToList(); + + public IReadOnlyList EnumerateDirectories(string path) => + Directory.EnumerateDirectories(path).ToList(); + + public Task ReadAllBytesAsync(string path, CancellationToken cancellationToken = default) => + File.ReadAllBytesAsync(path, cancellationToken); + + public async Task WriteAllBytesAsync( + string path, + byte[] bytes, + CancellationToken cancellationToken = default + ) + { + var directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + await File.WriteAllBytesAsync(path, bytes, cancellationToken); + } + + public Task ReadAllTextAsync(string path, CancellationToken cancellationToken = default) => + File.ReadAllTextAsync(path, cancellationToken); + + public async Task WriteAllTextAsync( + string path, + string contents, + CancellationToken cancellationToken = default + ) + { + var directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + await File.WriteAllTextAsync(path, contents, new UTF8Encoding(false), cancellationToken); + } + + public void CopyFile(string source, string destination, bool overwrite) + { + var directory = Path.GetDirectoryName(destination); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + File.Copy(source, destination, overwrite); + } + + public DateTimeOffset GetLastWriteTime(string path) => new FileInfo(path).LastWriteTimeUtc; + + public long GetFileSize(string path) => new FileInfo(path).Length; +} diff --git a/CentralConfigGenerator.Core/IO/TextFileFormat.cs b/CentralConfigGenerator.Core/IO/TextFileFormat.cs new file mode 100644 index 0000000..80d6e94 --- /dev/null +++ b/CentralConfigGenerator.Core/IO/TextFileFormat.cs @@ -0,0 +1,44 @@ +using System.Text; +using CentralConfigGenerator.Core.Models; + +namespace CentralConfigGenerator.Core.IO; + +/// +/// Captures the on-disk representation of a text file so it can be rewritten byte-faithfully. +/// +public sealed record TextFileFormat +{ + public static readonly TextFileFormat Default = new() + { + Encoding = new UTF8Encoding(false), + HasByteOrderMark = false, + LineEnding = LineEndingStyle.Preserve, + EndsWithNewLine = true, + }; + + public required Encoding Encoding { get; init; } + + public required bool HasByteOrderMark { get; init; } + + public required LineEndingStyle LineEnding { get; init; } + + public required bool EndsWithNewLine { get; init; } + + public Encoding ToWriteEncoding() + { + if (Encoding is UTF8Encoding) + { + return new UTF8Encoding(HasByteOrderMark); + } + + if (HasByteOrderMark) + { + return Encoding; + } + + // Strip the preamble for encodings that emit one by default (UTF-16/32). + return Encoding.GetPreamble().Length == 0 + ? Encoding + : (Encoding)new UTF8Encoding(false); + } +} diff --git a/CentralConfigGenerator.Core/Migration/IMigrationService.cs b/CentralConfigGenerator.Core/Migration/IMigrationService.cs new file mode 100644 index 0000000..1e73b82 --- /dev/null +++ b/CentralConfigGenerator.Core/Migration/IMigrationService.cs @@ -0,0 +1,26 @@ +using CentralConfigGenerator.Core.Models; + +namespace CentralConfigGenerator.Core.Migration; + +public interface IMigrationService +{ + /// Plans a migration to central package management without touching disk. + Task PlanAsync( + MigrationOptions options, + Func? conflictPrompt = null, + CancellationToken cancellationToken = default + ); + + /// Plans the reverse migration: central versions pushed back into project files. + Task PlanRevertAsync( + MigrationOptions options, + CancellationToken cancellationToken = default + ); + + /// Writes a plan to disk, taking a backup first unless disabled. + Task ApplyAsync( + MigrationPlan plan, + MigrationOptions options, + CancellationToken cancellationToken = default + ); +} diff --git a/CentralConfigGenerator.Core/Migration/MigrationOptions.cs b/CentralConfigGenerator.Core/Migration/MigrationOptions.cs new file mode 100644 index 0000000..008b010 --- /dev/null +++ b/CentralConfigGenerator.Core/Migration/MigrationOptions.cs @@ -0,0 +1,110 @@ +using CentralConfigGenerator.Core.Models; +using CentralConfigGenerator.Core.Services; +using NuGet.Versioning; + +namespace CentralConfigGenerator.Core.Migration; + +/// Everything that shapes a migration to (or away from) central package management. +public sealed record MigrationOptions +{ + public string RootDirectory { get; init; } = Directory.GetCurrentDirectory(); + + public string? SolutionPath { get; init; } + + public string? ProjectPath { get; init; } + + /// Where Directory.Packages.props is written. Defaults to . + public string? OutputDirectory { get; init; } + + public string? ExcludePattern { get; init; } + + /// Merge into an existing Directory.Packages.props instead of replacing it. + public bool Merge { get; init; } + + /// Leave inline Version attributes in place (analysis-only migrations). + public bool KeepInlineVersions { get; init; } + + public VersionResolutionStrategy ConflictStrategy { get; init; } = + VersionResolutionStrategy.Highest; + + /// Fail the run instead of auto-resolving when projects disagree on a version. + public bool FailOnConflict { get; init; } + + public bool IgnorePrerelease { get; init; } + + public bool TransitivePinning { get; init; } + + public VersionComparison VersionComparison { get; init; } = VersionComparison.VersionRelease; + + public LineEndingStyle LineEnding { get; init; } = LineEndingStyle.Preserve; + + public string? EncodingName { get; init; } + + public bool CreateBackup { get; init; } = true; + + public string? BackupDirectory { get; init; } + + public bool AddGitIgnore { get; init; } + + /// Also promote shared project properties into Directory.Build.props. + public bool UnifyProperties { get; init; } + + public IReadOnlyList BuildProperties { get; init; } = + BuildPropertyDefaults.Recommended; + + public IReadOnlyList IncludedKinds { get; init; } = + [ProjectKind.CSharp, ProjectKind.FSharp, ProjectKind.VisualBasic]; + + public string ResolvedOutputDirectory => + Path.GetFullPath( + string.IsNullOrWhiteSpace(OutputDirectory) ? RootDirectory : OutputDirectory + ); + + public string PackagesPropsPath => + Path.Combine(ResolvedOutputDirectory, "Directory.Packages.props"); + + public string BuildPropsPath => Path.Combine(ResolvedOutputDirectory, "Directory.Build.props"); +} + +/// Property names that are safe and useful to hoist into Directory.Build.props. +public static class BuildPropertyDefaults +{ + public static readonly IReadOnlyList Recommended = + [ + "TargetFramework", + "TargetFrameworks", + "ImplicitUsings", + "Nullable", + "LangVersion", + "AnalysisLevel", + "EnforceCodeStyleInBuild", + "TreatWarningsAsErrors", + "GenerateDocumentationFile", + "InvariantGlobalization", + "Authors", + "Company", + "Product", + "Copyright", + "PackageLicenseExpression", + "PackageProjectUrl", + "RepositoryUrl", + "RepositoryType", + "Version", + "VersionPrefix", + ]; + + /// Properties that must never be hoisted because they are project-identity specific. + public static readonly IReadOnlyList NeverHoist = + [ + "AssemblyName", + "RootNamespace", + "OutputType", + "ProjectGuid", + "UserSecretsId", + "StartupObject", + "PackageId", + "ApplicationIcon", + "TargetFrameworkIdentifier", + "TargetFrameworkVersion", + ]; +} diff --git a/CentralConfigGenerator.Core/Migration/MigrationPlan.cs b/CentralConfigGenerator.Core/Migration/MigrationPlan.cs new file mode 100644 index 0000000..6a3e476 --- /dev/null +++ b/CentralConfigGenerator.Core/Migration/MigrationPlan.cs @@ -0,0 +1,75 @@ +using CentralConfigGenerator.Core.Analysis; +using CentralConfigGenerator.Core.Models; + +namespace CentralConfigGenerator.Core.Migration; + +public enum FileChangeKind +{ + Created, + Modified, + Deleted, +} + +/// A single pending file write, kept in memory so dry-run and diff cost nothing extra. +public sealed record FileChange +{ + public required string Path { get; init; } + + public required FileChangeKind Kind { get; init; } + + public string OriginalContent { get; init; } = string.Empty; + + public string NewContent { get; init; } = string.Empty; + + public required IO.TextFileFormat Format { get; init; } + + public string Diff(int context = 3) => + UnifiedDiffFor(this, context); + + private static string UnifiedDiffFor(FileChange change, int context) => + Services.UnifiedDiff.Create( + change.OriginalContent, + change.NewContent, + change.Kind == FileChangeKind.Created ? "/dev/null" : change.Path, + change.Kind == FileChangeKind.Deleted ? "/dev/null" : change.Path, + context + ); +} + +/// A version disagreement between two or more projects. +public sealed record PackageConflict +{ + public required string PackageId { get; init; } + + public required IReadOnlyList References { get; init; } + + public required string ResolvedVersion { get; init; } + + public IEnumerable DistinctVersions => + References + .Select(r => r.Version) + .Where(v => !string.IsNullOrEmpty(v)) + .Select(v => v!) + .Distinct(StringComparer.OrdinalIgnoreCase); +} + +/// The complete, still-unapplied result of planning a migration. +public sealed record MigrationPlan +{ + public required IReadOnlyList Changes { get; init; } + + public required IReadOnlyDictionary ResolvedVersions { get; init; } + + public required IReadOnlyList Conflicts { get; init; } + + public required IReadOnlyList Findings { get; init; } + + public required int ProjectCount { get; init; } + + public int PackageCount => ResolvedVersions.Count; + + public bool HasChanges => Changes.Count > 0; + + public IReadOnlyList AffectedPaths => + Changes.Select(c => c.Path).ToList(); +} diff --git a/CentralConfigGenerator.Core/Migration/MigrationService.cs b/CentralConfigGenerator.Core/Migration/MigrationService.cs new file mode 100644 index 0000000..dcfac6f --- /dev/null +++ b/CentralConfigGenerator.Core/Migration/MigrationService.cs @@ -0,0 +1,452 @@ +using CentralConfigGenerator.Core.Analysis; +using CentralConfigGenerator.Core.Discovery; +using CentralConfigGenerator.Core.Generators; +using CentralConfigGenerator.Core.IO; +using CentralConfigGenerator.Core.Models; +using CentralConfigGenerator.Core.Services; +using CentralConfigGenerator.Core.Services.Abstractions; +using CentralConfigGenerator.Core.Xml; + +namespace CentralConfigGenerator.Core.Migration; + +/// +/// Plans and applies the migration between inline package versions and central package +/// management. Planning never writes to disk, which is what makes dry-run, diff and rollback +/// all fall out of the same code path. +/// +public sealed class MigrationService( + IProjectDiscoveryService discovery, + IFileSystem fileSystem, + IBackupService backupService, + VersionSelector versionSelector +) : IMigrationService +{ + public async Task PlanAsync( + MigrationOptions options, + Func? conflictPrompt = null, + CancellationToken cancellationToken = default + ) + { + var projects = await DiscoverAsync(options, cancellationToken); + if (projects.Count == 0) + { + return EmptyPlan(); + } + + var references = projects + .SelectMany(PackageReferenceReader.Read) + .Where(r => !string.IsNullOrWhiteSpace(r.Version)) + .ToList(); + + var grouped = references + .GroupBy(r => r.PackageId, StringComparer.OrdinalIgnoreCase) + .OrderBy(g => g.Key, StringComparer.OrdinalIgnoreCase) + .ToList(); + + var resolved = new Dictionary(StringComparer.OrdinalIgnoreCase); + var conflicts = new List(); + var findings = new List(); + + foreach (var group in grouped) + { + var versions = group + .Select(r => r.Version!) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + string chosen; + + if (versions.Count == 1) + { + chosen = versions[0]; + } + else + { + var pending = new PackageConflict + { + PackageId = group.Key, + References = group.ToList(), + ResolvedVersion = string.Empty, + }; + + if (options.FailOnConflict && conflictPrompt is null) + { + throw new VersionConflictException(group.Key, versions); + } + + chosen = + conflictPrompt?.Invoke(pending) + ?? versionSelector.Select( + group.Key, + versions, + options.ConflictStrategy, + options.IgnorePrerelease, + options.VersionComparison + ); + + conflicts.Add(pending with { ResolvedVersion = chosen }); + + findings.Add( + new Finding + { + RuleId = RuleIds.VersionInconsistency, + Severity = Severity.Moderate, + PackageId = group.Key, + Version = chosen, + Message = + $"'{group.Key}' is referenced with {versions.Count} different versions ({string.Join(", ", versions)}).", + Recommendation = $"Unified on {chosen}.", + } + ); + } + + resolved[group.Key] = chosen; + } + + var globals = references + .Where(r => r.IsGlobal) + .Select(r => r.PackageId) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + var changes = new List(); + + changes.Add( + await BuildPackagesPropsChangeAsync(options, resolved, globals, cancellationToken) + ); + + if (!options.KeepInlineVersions) + { + foreach (var project in projects) + { + cancellationToken.ThrowIfCancellationRequested(); + + var updated = ProjectRewriter.StripVersions(project, out _); + if (updated is not null) + { + changes.Add(ToChange(project, updated)); + } + } + } + + if (options.UnifyProperties) + { + changes.AddRange(await BuildPropertyChangesAsync(options, projects, cancellationToken)); + } + + return new MigrationPlan + { + Changes = changes, + ResolvedVersions = resolved, + Conflicts = conflicts, + Findings = findings, + ProjectCount = projects.Count, + }; + } + + public async Task PlanRevertAsync( + MigrationOptions options, + CancellationToken cancellationToken = default + ) + { + var projects = await DiscoverAsync(options, cancellationToken); + if (projects.Count == 0) + { + return EmptyPlan(); + } + + var propsPath = options.PackagesPropsPath; + var centralVersions = new Dictionary(StringComparer.OrdinalIgnoreCase); + var findings = new List(); + + if (fileSystem.FileExists(propsPath)) + { + var propsFile = await discovery.LoadAsync(propsPath, cancellationToken); + if ( + propsFile is not null + && MsBuildXml.TryParse(propsFile.Content, out var propsDocument, out _) + ) + { + foreach (var pair in PackageReferenceReader.ReadPackageVersions(propsDocument)) + { + centralVersions[pair.Key] = pair.Value; + } + } + } + + var changes = new List(); + + foreach (var project in projects) + { + cancellationToken.ThrowIfCancellationRequested(); + + var updated = ProjectRewriter.RestoreVersions(project, centralVersions, out var missing); + + foreach (var packageId in missing) + { + findings.Add( + new Finding + { + RuleId = RuleIds.MissingCentralVersion, + Severity = Severity.High, + PackageId = packageId, + ProjectPath = project.Path, + Message = + $"'{packageId}' has no PackageVersion entry, so no version could be restored.", + Recommendation = + "Add the package to Directory.Packages.props or set the version manually.", + } + ); + } + + if (updated is not null) + { + changes.Add(ToChange(project, updated)); + } + } + + if (fileSystem.FileExists(propsPath)) + { + var propsFile = await discovery.LoadAsync(propsPath, cancellationToken); + if (propsFile is not null) + { + changes.Add( + new FileChange + { + Path = propsPath, + Kind = FileChangeKind.Deleted, + OriginalContent = propsFile.Content, + NewContent = string.Empty, + Format = propsFile.Format, + } + ); + } + } + + return new MigrationPlan + { + Changes = changes, + ResolvedVersions = centralVersions, + Conflicts = [], + Findings = findings, + ProjectCount = projects.Count, + }; + } + + public async Task ApplyAsync( + MigrationPlan plan, + MigrationOptions options, + CancellationToken cancellationToken = default + ) + { + BackupSet? backup = null; + + if (options.CreateBackup && plan.HasChanges) + { + backup = await backupService.CreateAsync( + options.RootDirectory, + options.BackupDirectory ?? options.RootDirectory, + "migrate", + plan.AffectedPaths, + cancellationToken + ); + } + + if (options.AddGitIgnore) + { + await backupService.EnsureGitIgnoreAsync( + options.BackupDirectory ?? options.RootDirectory, + BackupService.DefaultBackupFolderName, + cancellationToken + ); + } + + foreach (var change in plan.Changes) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (change.Kind == FileChangeKind.Deleted) + { + if (fileSystem.FileExists(change.Path)) + { + fileSystem.DeleteFile(change.Path); + } + + continue; + } + + var format = ApplyFormatOverrides(change.Format, options); + var bytes = EncodingDetector.Encode(change.NewContent, format); + await fileSystem.WriteAllBytesAsync(change.Path, bytes, cancellationToken); + } + + return backup; + } + + private static TextFileFormat ApplyFormatOverrides( + TextFileFormat format, + MigrationOptions options + ) + { + var encoding = EncodingDetector.Resolve(options.EncodingName); + + return format with + { + Encoding = encoding ?? format.Encoding, + HasByteOrderMark = encoding is not null + ? encoding.GetPreamble().Length > 0 && encoding is not System.Text.UTF8Encoding + : format.HasByteOrderMark, + LineEnding = + options.LineEnding == LineEndingStyle.Preserve + ? format.LineEnding + : options.LineEnding, + }; + } + + private async Task> DiscoverAsync( + MigrationOptions options, + CancellationToken cancellationToken + ) => + await discovery.DiscoverAsync( + new DiscoveryOptions + { + RootDirectory = options.RootDirectory, + SolutionPath = options.SolutionPath, + ProjectPath = options.ProjectPath, + ExcludePattern = options.ExcludePattern, + IncludedKinds = options.IncludedKinds.ToList(), + }, + cancellationToken + ); + + private async Task BuildPackagesPropsChangeAsync( + MigrationOptions options, + IReadOnlyDictionary resolved, + IReadOnlyCollection globals, + CancellationToken cancellationToken + ) + { + var path = options.PackagesPropsPath; + var writerOptions = new PackagesPropsOptions + { + TransitivePinning = options.TransitivePinning, + GlobalPackages = globals, + }; + + if (fileSystem.FileExists(path)) + { + var existing = await discovery.LoadAsync(path, cancellationToken); + var original = existing?.Content ?? string.Empty; + + var content = options.Merge + ? PackagesPropsWriter.Merge(original, resolved, writerOptions) + : PackagesPropsWriter.Create(resolved, writerOptions); + + return new FileChange + { + Path = path, + Kind = FileChangeKind.Modified, + OriginalContent = original, + NewContent = content, + Format = existing?.Format ?? TextFileFormat.Default, + }; + } + + return new FileChange + { + Path = path, + Kind = FileChangeKind.Created, + OriginalContent = string.Empty, + NewContent = PackagesPropsWriter.Create(resolved, writerOptions), + Format = TextFileFormat.Default, + }; + } + + private async Task> BuildPropertyChangesAsync( + MigrationOptions options, + IReadOnlyList projects, + CancellationToken cancellationToken + ) + { + var hoisted = PropertyHoister.FindHoistableProperties(projects, options.BuildProperties); + if (hoisted.Count == 0) + { + return []; + } + + var changes = new List(); + var path = options.BuildPropsPath; + + if (fileSystem.FileExists(path)) + { + var existing = await discovery.LoadAsync(path, cancellationToken); + var original = existing?.Content ?? string.Empty; + + changes.Add( + new FileChange + { + Path = path, + Kind = FileChangeKind.Modified, + OriginalContent = original, + NewContent = BuildPropsWriter.Merge(original, hoisted, options.BuildProperties), + Format = existing?.Format ?? TextFileFormat.Default, + } + ); + } + else + { + changes.Add( + new FileChange + { + Path = path, + Kind = FileChangeKind.Created, + OriginalContent = string.Empty, + NewContent = BuildPropsWriter.Create(hoisted, options.BuildProperties), + Format = TextFileFormat.Default, + } + ); + } + + foreach (var project in projects) + { + var updated = ProjectRewriter.RemoveProperties(project, hoisted.Keys, hoisted); + if (updated is null) + { + continue; + } + + var existingChange = changes.FirstOrDefault(c => + string.Equals(c.Path, project.Path, StringComparison.OrdinalIgnoreCase) + ); + + if (existingChange is not null) + { + changes.Remove(existingChange); + } + + changes.Add(ToChange(project, updated)); + } + + return changes; + } + + private static FileChange ToChange(ProjectFile project, string updatedContent) => + new() + { + Path = project.Path, + Kind = FileChangeKind.Modified, + OriginalContent = project.Content, + NewContent = updatedContent, + Format = project.Format, + }; + + private static MigrationPlan EmptyPlan() => + new() + { + Changes = [], + ResolvedVersions = new Dictionary(), + Conflicts = [], + Findings = [], + ProjectCount = 0, + }; +} diff --git a/CentralConfigGenerator.Core/Migration/ProjectRewriter.cs b/CentralConfigGenerator.Core/Migration/ProjectRewriter.cs new file mode 100644 index 0000000..e6e4455 --- /dev/null +++ b/CentralConfigGenerator.Core/Migration/ProjectRewriter.cs @@ -0,0 +1,328 @@ +using System.Xml.Linq; +using CentralConfigGenerator.Core.Models; +using CentralConfigGenerator.Core.Xml; + +namespace CentralConfigGenerator.Core.Migration; + +/// +/// Surgical edits to a single project file: stripping inline versions for a migration, putting +/// them back for a revert, and removing properties that moved to Directory.Build.props. +/// +public static class ProjectRewriter +{ + /// + /// Removes inline Version from every PackageReference. + /// Returns null when nothing changed or the file could not be parsed. + /// + public static string? StripVersions(ProjectFile projectFile, out IReadOnlyList touched) + { + touched = []; + + if (!MsBuildXml.TryParse(projectFile.Content, out var document, out _)) + { + return null; + } + + var changed = false; + var packages = new List(); + + foreach (var element in MsBuildXml.Descendants(document, "PackageReference")) + { + var id = MsBuildXml.AttributeValue(element, "Include") + ?? MsBuildXml.AttributeValue(element, "Update"); + + var attribute = MsBuildXml.Attribute(element, "Version"); + if (attribute is not null) + { + attribute.Remove(); + changed = true; + if (id is not null) + { + packages.Add(id); + } + + continue; + } + + var child = MsBuildXml.Elements(element, "Version").FirstOrDefault(); + if (child is null) + { + continue; + } + + MsBuildXml.RemoveWithWhitespace(child); + CollapseIfEmpty(element); + changed = true; + if (id is not null) + { + packages.Add(id); + } + } + + touched = packages; + return changed ? MsBuildXml.ToString(document) : null; + } + + /// Removes the inline version of one specific package from a project. + public static string? StripVersion(ProjectFile projectFile, string packageId) + { + if (!MsBuildXml.TryParse(projectFile.Content, out var document, out _)) + { + return null; + } + + var changed = false; + + foreach (var element in MsBuildXml.Descendants(document, "PackageReference")) + { + var id = + MsBuildXml.AttributeValue(element, "Include") + ?? MsBuildXml.AttributeValue(element, "Update"); + + if (!string.Equals(id, packageId, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var attribute = MsBuildXml.Attribute(element, "Version"); + if (attribute is not null) + { + attribute.Remove(); + changed = true; + continue; + } + + var child = MsBuildXml.Elements(element, "Version").FirstOrDefault(); + if (child is not null) + { + MsBuildXml.RemoveWithWhitespace(child); + CollapseIfEmpty(element); + changed = true; + } + } + + return changed ? MsBuildXml.ToString(document) : null; + } + + /// + /// Writes versions back onto every PackageReference that lacks one, using the supplied + /// central version map. Used by the revert command. + /// + public static string? RestoreVersions( + ProjectFile projectFile, + IReadOnlyDictionary centralVersions, + out IReadOnlyList unresolved + ) + { + var missing = new List(); + unresolved = missing; + + if (!MsBuildXml.TryParse(projectFile.Content, out var document, out _)) + { + return null; + } + + var changed = false; + + foreach (var element in MsBuildXml.Descendants(document, "PackageReference")) + { + if (MsBuildXml.Attribute(element, "Version") is not null) + { + continue; + } + + if (MsBuildXml.Elements(element, "Version").Any()) + { + continue; + } + + var id = + MsBuildXml.AttributeValue(element, "Include") + ?? MsBuildXml.AttributeValue(element, "Update"); + + if (string.IsNullOrWhiteSpace(id)) + { + continue; + } + + if (!centralVersions.TryGetValue(id, out var version)) + { + missing.Add(id); + continue; + } + + InsertVersionAfterInclude(element, version); + changed = true; + } + + return changed ? MsBuildXml.ToString(document) : null; + } + + /// Removes the named properties from a project because they were hoisted upwards. + public static string? RemoveProperties( + ProjectFile projectFile, + IReadOnlyCollection propertyNames, + IReadOnlyDictionary? onlyWhenValueMatches = null + ) + { + if (propertyNames.Count == 0) + { + return null; + } + + if (!MsBuildXml.TryParse(projectFile.Content, out var document, out _)) + { + return null; + } + + var names = new HashSet(propertyNames, StringComparer.OrdinalIgnoreCase); + var changed = false; + + foreach (var group in MsBuildXml.Descendants(document, "PropertyGroup").ToList()) + { + // Conditioned property groups are project-specific; leave them alone. + if (!string.IsNullOrWhiteSpace(MsBuildXml.AttributeValue(group, "Condition"))) + { + continue; + } + + foreach (var element in group.Elements().ToList()) + { + if (!names.Contains(element.Name.LocalName)) + { + continue; + } + + if ( + onlyWhenValueMatches is not null + && onlyWhenValueMatches.TryGetValue(element.Name.LocalName, out var expected) + && !string.Equals(element.Value.Trim(), expected, StringComparison.Ordinal) + ) + { + continue; + } + + MsBuildXml.RemoveWithWhitespace(element); + changed = true; + } + + MsBuildXml.RemoveIfEmpty(group); + } + + return changed ? MsBuildXml.ToString(document) : null; + } + + /// Removes a duplicate PackageReference element for the given package id. + public static string? RemoveDuplicateReference(ProjectFile projectFile, string packageId) + { + if (!MsBuildXml.TryParse(projectFile.Content, out var document, out _)) + { + return null; + } + + var matches = MsBuildXml + .Descendants(document, "PackageReference") + .Where(e => + string.Equals( + MsBuildXml.AttributeValue(e, "Include"), + packageId, + StringComparison.OrdinalIgnoreCase + ) + ) + .ToList(); + + if (matches.Count < 2) + { + return null; + } + + foreach (var duplicate in matches.Skip(1)) + { + var parent = duplicate.Parent; + MsBuildXml.RemoveWithWhitespace(duplicate); + MsBuildXml.RemoveIfEmpty(parent); + } + + return MsBuildXml.ToString(document); + } + + /// Rewrites a package id so its casing matches the canonical spelling from the feed. + public static string? NormalizeCasing(ProjectFile projectFile, string canonicalId) + { + if (!MsBuildXml.TryParse(projectFile.Content, out var document, out _)) + { + return null; + } + + var changed = false; + + foreach (var element in MsBuildXml.Descendants(document, "PackageReference")) + { + var attribute = MsBuildXml.Attribute(element, "Include"); + if ( + attribute is null + || !string.Equals(attribute.Value, canonicalId, StringComparison.OrdinalIgnoreCase) + || string.Equals(attribute.Value, canonicalId, StringComparison.Ordinal) + ) + { + continue; + } + + attribute.Value = canonicalId; + changed = true; + } + + return changed ? MsBuildXml.ToString(document) : null; + } + + /// + /// Adds a Version attribute directly after Include. LINQ to XML has no attribute-insert API, + /// so the attribute list is rebuilt in the desired order. + /// + private static void InsertVersionAfterInclude(XElement element, string version) + { + var attributes = element.Attributes().ToList(); + element.RemoveAttributes(); + + var inserted = false; + + foreach (var attribute in attributes) + { + element.Add(new XAttribute(attribute.Name, attribute.Value)); + + if ( + inserted + || !string.Equals(attribute.Name.LocalName, "Include", StringComparison.OrdinalIgnoreCase) + && !string.Equals(attribute.Name.LocalName, "Update", StringComparison.OrdinalIgnoreCase) + ) + { + continue; + } + + element.Add(new XAttribute("Version", version)); + inserted = true; + } + + if (!inserted) + { + element.Add(new XAttribute("Version", version)); + } + } + + private static void CollapseIfEmpty(XElement element) + { + if (element.Elements().Any()) + { + return; + } + + // Drop leftover whitespace so \n becomes self-closing. + foreach (var node in element.Nodes().OfType().ToList()) + { + if (node.Value.Trim().Length == 0) + { + node.Remove(); + } + } + } +} diff --git a/CentralConfigGenerator.Core/Migration/PropertyHoister.cs b/CentralConfigGenerator.Core/Migration/PropertyHoister.cs new file mode 100644 index 0000000..8199c83 --- /dev/null +++ b/CentralConfigGenerator.Core/Migration/PropertyHoister.cs @@ -0,0 +1,100 @@ +using CentralConfigGenerator.Core.Models; +using CentralConfigGenerator.Core.Xml; + +namespace CentralConfigGenerator.Core.Migration; + +/// +/// Works out which MSBuild properties every project agrees on, so they can be lifted into +/// Directory.Build.props without changing the evaluated build. +/// +public static class PropertyHoister +{ + /// + /// Returns the properties whose value is identical across all projects that declare + /// them, and that are declared by at least two projects. Requiring unanimity is what keeps the + /// migration behaviour-preserving. + /// + public static Dictionary FindHoistableProperties( + IReadOnlyCollection projects, + IReadOnlyList candidateNames, + int minimumProjects = 2 + ) + { + var allowed = new HashSet(candidateNames, StringComparer.OrdinalIgnoreCase); + var blocked = new HashSet( + BuildPropertyDefaults.NeverHoist, + StringComparer.OrdinalIgnoreCase + ); + + var observed = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var declaringProjects = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var project in projects) + { + if (!MsBuildXml.TryParse(project.Content, out var document, out _)) + { + continue; + } + + var seenInThisProject = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var group in MsBuildXml.Descendants(document, "PropertyGroup")) + { + // Conditioned groups vary per configuration and are never safe to hoist. + if (!string.IsNullOrWhiteSpace(MsBuildXml.AttributeValue(group, "Condition"))) + { + continue; + } + + foreach (var element in group.Elements()) + { + var name = element.Name.LocalName; + + if (blocked.Contains(name) || (allowed.Count > 0 && !allowed.Contains(name))) + { + continue; + } + + var value = element.Value.Trim(); + if (value.Length == 0) + { + continue; + } + + if (!observed.TryGetValue(name, out var values)) + { + values = new HashSet(StringComparer.Ordinal); + observed[name] = values; + } + + values.Add(value); + seenInThisProject.Add(name); + } + } + + foreach (var name in seenInThisProject) + { + declaringProjects[name] = declaringProjects.GetValueOrDefault(name) + 1; + } + } + + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var (name, values) in observed) + { + if (values.Count != 1) + { + continue; + } + + if (declaringProjects.GetValueOrDefault(name) < minimumProjects) + { + continue; + } + + result[name] = values.First(); + } + + return result; + } +} diff --git a/CentralConfigGenerator.Core/Models/BackupSet.cs b/CentralConfigGenerator.Core/Models/BackupSet.cs new file mode 100644 index 0000000..7253d42 --- /dev/null +++ b/CentralConfigGenerator.Core/Models/BackupSet.cs @@ -0,0 +1,44 @@ +using System.Text.Json.Serialization; + +namespace CentralConfigGenerator.Core.Models; + +/// A single timestamped backup produced before a mutating operation. +public sealed record BackupSet +{ + public required string Id { get; init; } + + public required string Path { get; init; } + + public required DateTimeOffset CreatedAt { get; init; } + + public required string Operation { get; init; } + + public required IReadOnlyList Files { get; init; } + + [JsonIgnore] + public long SizeInBytes { get; init; } +} + +/// Manifest persisted alongside the copied files inside a backup directory. +public sealed record BackupManifest +{ + public string Id { get; init; } = string.Empty; + + public DateTimeOffset CreatedAt { get; init; } + + public string Operation { get; init; } = string.Empty; + + public string RootDirectory { get; init; } = string.Empty; + + public List Entries { get; init; } = []; +} + +public sealed record BackupEntry +{ + public string OriginalPath { get; init; } = string.Empty; + + public string RelativePath { get; init; } = string.Empty; + + /// True when the file did not exist before the operation and should be deleted on rollback. + public bool WasCreated { get; init; } +} diff --git a/CentralConfigGenerator.Core/Models/ExitCode.cs b/CentralConfigGenerator.Core/Models/ExitCode.cs new file mode 100644 index 0000000..05aedf1 --- /dev/null +++ b/CentralConfigGenerator.Core/Models/ExitCode.cs @@ -0,0 +1,32 @@ +namespace CentralConfigGenerator.Core.Models; + +/// Process exit codes, stable across releases so CI can branch on them. +public static class ExitCode +{ + public const int Success = 0; + public const int ValidationError = 1; + public const int FileOperationError = 2; + public const int VersionConflict = 3; + public const int NoProjectsFound = 4; + public const int AnalysisIssuesFound = 5; + public const int UnexpectedError = 6; + public const int TestFailure = 7; + public const int IncompleteAnalysis = 8; + public const int GraphDrift = 9; + + public static string Describe(int code) => + code switch + { + Success => "Success", + ValidationError => "ValidationError", + FileOperationError => "FileOperationError", + VersionConflict => "VersionConflict", + NoProjectsFound => "NoProjectsFound", + AnalysisIssuesFound => "AnalysisIssuesFound", + UnexpectedError => "UnexpectedError", + TestFailure => "TestFailure", + IncompleteAnalysis => "IncompleteAnalysis", + GraphDrift => "GraphDrift", + _ => "Unknown", + }; +} diff --git a/CentralConfigGenerator.Core/Models/LineEndingStyle.cs b/CentralConfigGenerator.Core/Models/LineEndingStyle.cs new file mode 100644 index 0000000..d8555c9 --- /dev/null +++ b/CentralConfigGenerator.Core/Models/LineEndingStyle.cs @@ -0,0 +1,82 @@ +namespace CentralConfigGenerator.Core.Models; + +/// +/// Line ending style used when writing files back to disk. +/// +public enum LineEndingStyle +{ + /// Keep whatever the original file used. + Preserve = 0, + Lf, + CrLf, + Cr, +} + +public static class LineEndingStyleExtensions +{ + public static string ToNewLine(this LineEndingStyle style) => + style switch + { + LineEndingStyle.Lf => "\n", + LineEndingStyle.CrLf => "\r\n", + LineEndingStyle.Cr => "\r", + _ => Environment.NewLine, + }; + + public static LineEndingStyle Detect(string content) + { + var crlf = 0; + var lf = 0; + var cr = 0; + + for (var i = 0; i < content.Length; i++) + { + var c = content[i]; + if (c == '\r') + { + if (i + 1 < content.Length && content[i + 1] == '\n') + { + crlf++; + i++; + } + else + { + cr++; + } + } + else if (c == '\n') + { + lf++; + } + } + + if (crlf == 0 && lf == 0 && cr == 0) + { + return LineEndingStyle.Preserve; + } + + if (crlf >= lf && crlf >= cr) + { + return LineEndingStyle.CrLf; + } + + return lf >= cr ? LineEndingStyle.Lf : LineEndingStyle.Cr; + } + + public static string Normalize(string content, LineEndingStyle style) + { + if (style == LineEndingStyle.Preserve) + { + return content; + } + + var unified = content.Replace("\r\n", "\n").Replace('\r', '\n'); + return style switch + { + LineEndingStyle.Lf => unified, + LineEndingStyle.CrLf => unified.Replace("\n", "\r\n"), + LineEndingStyle.Cr => unified.Replace('\n', '\r'), + _ => content, + }; + } +} diff --git a/CentralConfigGenerator.Core/Models/PackageReferenceInfo.cs b/CentralConfigGenerator.Core/Models/PackageReferenceInfo.cs new file mode 100644 index 0000000..44c4da2 --- /dev/null +++ b/CentralConfigGenerator.Core/Models/PackageReferenceInfo.cs @@ -0,0 +1,26 @@ +namespace CentralConfigGenerator.Core.Models; + +/// +/// A single PackageReference (or GlobalPackageReference) as declared by a project. +/// +public sealed record PackageReferenceInfo +{ + public required string PackageId { get; init; } + + /// The declared version string, or null when the reference has no version (CPM style). + public string? Version { get; init; } + + public required string ProjectPath { get; init; } + + /// The MSBuild Condition on the reference or its item group, when present. + public string? Condition { get; init; } + + /// True when the version came from a child element rather than an attribute. + public bool VersionIsElement { get; init; } + + /// True for GlobalPackageReference items. + public bool IsGlobal { get; init; } + + /// True when the reference targets a private/development dependency. + public bool IsPrivateAssets { get; init; } +} diff --git a/CentralConfigGenerator.Core/Models/ProjectFile.cs b/CentralConfigGenerator.Core/Models/ProjectFile.cs index b2d6782..dfc0508 100644 --- a/CentralConfigGenerator.Core/Models/ProjectFile.cs +++ b/CentralConfigGenerator.Core/Models/ProjectFile.cs @@ -1,8 +1,27 @@ -namespace CentralConfigGenerator.Core.Models; +using CentralConfigGenerator.Core.IO; +namespace CentralConfigGenerator.Core.Models; + +/// +/// An MSBuild file that was read from disk, together with everything needed to write it back +/// without disturbing its original encoding or line endings. +/// public record ProjectFile { public string Path { get; set; } = string.Empty; - + public string Content { get; set; } = string.Empty; -} \ No newline at end of file + + /// The kind of MSBuild file, inferred from the extension when not set explicitly. + public ProjectKind Kind { get; set; } = ProjectKind.Unknown; + + /// The original on-disk text format. Defaults to BOM-less UTF-8. + public TextFileFormat Format { get; set; } = TextFileFormat.Default; + + public ProjectKind ResolvedKind => + Kind != ProjectKind.Unknown ? Kind : ProjectKindExtensions.FromPath(Path); + + public string FileName => System.IO.Path.GetFileName(Path); + + public string DirectoryPath => System.IO.Path.GetDirectoryName(Path) ?? string.Empty; +} diff --git a/CentralConfigGenerator.Core/Models/ProjectKind.cs b/CentralConfigGenerator.Core/Models/ProjectKind.cs new file mode 100644 index 0000000..0048954 --- /dev/null +++ b/CentralConfigGenerator.Core/Models/ProjectKind.cs @@ -0,0 +1,31 @@ +namespace CentralConfigGenerator.Core.Models; + +/// +/// The kind of MSBuild file that was discovered on disk. +/// +public enum ProjectKind +{ + Unknown = 0, + CSharp, + FSharp, + VisualBasic, + Props, + Targets, +} + +public static class ProjectKindExtensions +{ + public static ProjectKind FromPath(string path) => + Path.GetExtension(path).ToLowerInvariant() switch + { + ".csproj" => ProjectKind.CSharp, + ".fsproj" => ProjectKind.FSharp, + ".vbproj" => ProjectKind.VisualBasic, + ".props" => ProjectKind.Props, + ".targets" => ProjectKind.Targets, + _ => ProjectKind.Unknown, + }; + + public static bool IsProject(this ProjectKind kind) => + kind is ProjectKind.CSharp or ProjectKind.FSharp or ProjectKind.VisualBasic; +} diff --git a/CentralConfigGenerator.Core/NuGet/INuGetFeedService.cs b/CentralConfigGenerator.Core/NuGet/INuGetFeedService.cs new file mode 100644 index 0000000..e1fffbd --- /dev/null +++ b/CentralConfigGenerator.Core/NuGet/INuGetFeedService.cs @@ -0,0 +1,21 @@ +namespace CentralConfigGenerator.Core.NuGet; + +public interface INuGetFeedService +{ + /// Fetches metadata for a single package, memoised for the lifetime of the service. + Task GetMetadataAsync( + string packageId, + CancellationToken cancellationToken = default + ); + + /// Fetches metadata for many packages with bounded parallelism. + Task> GetMetadataAsync( + IEnumerable packageIds, + int maxParallelism, + IProgress? progress = null, + CancellationToken cancellationToken = default + ); + + /// The configured package sources, for diagnostics. + IReadOnlyList Sources { get; } +} diff --git a/CentralConfigGenerator.Core/NuGet/NuGetFeedService.cs b/CentralConfigGenerator.Core/NuGet/NuGetFeedService.cs new file mode 100644 index 0000000..91aaf64 --- /dev/null +++ b/CentralConfigGenerator.Core/NuGet/NuGetFeedService.cs @@ -0,0 +1,234 @@ +using System.Collections.Concurrent; +using CentralConfigGenerator.Core.Analysis; +using NuGet.Common; +using NuGet.Configuration; +using NuGet.Protocol; +using NuGet.Protocol.Core.Types; +using NuGet.Versioning; + +namespace CentralConfigGenerator.Core.NuGet; + +/// +/// Talks to every package source configured for the workspace (nuget.config aware), collecting +/// version lists, deprecation notices, licence data and security advisories. +/// +public sealed class NuGetFeedService : INuGetFeedService, IDisposable +{ + private readonly List _repositories = []; + private readonly SourceCacheContext _cache = new(); + private readonly ConcurrentDictionary> _memo = + new(StringComparer.OrdinalIgnoreCase); + + public NuGetFeedService(string? rootDirectory = null) + { + var sources = LoadSources(rootDirectory); + + foreach (var source in sources) + { + _repositories.Add(Repository.Factory.GetCoreV3(source)); + } + + if (_repositories.Count == 0) + { + _repositories.Add( + Repository.Factory.GetCoreV3( + new PackageSource("https://api.nuget.org/v3/index.json") + ) + ); + } + + Sources = _repositories.Select(r => r.PackageSource.Source).ToList(); + } + + public IReadOnlyList Sources { get; } + + public Task GetMetadataAsync( + string packageId, + CancellationToken cancellationToken = default + ) => _memo.GetOrAdd(packageId, id => FetchAsync(id, cancellationToken)); + + public async Task> GetMetadataAsync( + IEnumerable packageIds, + int maxParallelism, + IProgress? progress = null, + CancellationToken cancellationToken = default + ) + { + var ids = packageIds + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + var results = new ConcurrentDictionary( + StringComparer.OrdinalIgnoreCase + ); + + using var throttle = new SemaphoreSlim(Math.Max(1, maxParallelism)); + + var tasks = ids.Select(async id => + { + await throttle.WaitAsync(cancellationToken); + try + { + results[id] = await GetMetadataAsync(id, cancellationToken); + progress?.Report(id); + } + finally + { + throttle.Release(); + } + }); + + await Task.WhenAll(tasks); + return results; + } + + private async Task FetchAsync( + string packageId, + CancellationToken cancellationToken + ) + { + Exception? lastError = null; + + foreach (var repository in _repositories) + { + try + { + var resource = await repository.GetResourceAsync( + cancellationToken + ); + + var metadata = ( + await resource.GetMetadataAsync( + packageId, + includePrerelease: true, + includeUnlisted: false, + _cache, + NullLogger.Instance, + cancellationToken + ) + ).ToList(); + + if (metadata.Count == 0) + { + continue; + } + + return await BuildSnapshotAsync(packageId, metadata, cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + lastError = ex; + } + } + + return lastError is not null + ? PackageMetadataSnapshot.Unavailable(packageId, lastError.Message) + : new PackageMetadataSnapshot { PackageId = packageId, Found = false }; + } + + private static async Task BuildSnapshotAsync( + string packageId, + List metadata, + CancellationToken cancellationToken + ) + { + var ordered = metadata.OrderByDescending(m => m.Identity.Version).ToList(); + var newest = ordered[0]; + + var deprecation = await SafeGetDeprecationAsync(newest); + var vulnerabilities = new List(); + + foreach (var entry in ordered) + { + if (entry.Vulnerabilities is null) + { + continue; + } + + foreach (var vulnerability in entry.Vulnerabilities) + { + vulnerabilities.Add( + new PackageVulnerability + { + Severity = MapSeverity(vulnerability.Severity), + AdvisoryUrl = vulnerability.AdvisoryUrl?.ToString() ?? string.Empty, + AffectedRange = entry.Identity.Version.ToNormalizedString(), + } + ); + } + } + + cancellationToken.ThrowIfCancellationRequested(); + + return new PackageMetadataSnapshot + { + PackageId = newest.Identity.Id, + Found = true, + AllVersions = ordered.Select(m => m.Identity.Version).ToList(), + LatestStable = ordered.FirstOrDefault(m => !m.Identity.Version.IsPrerelease)?.Identity.Version, + LatestIncludingPrerelease = newest.Identity.Version, + IsDeprecated = deprecation is not null, + DeprecationMessage = deprecation?.Message, + DeprecationReasons = deprecation?.Reasons?.ToList() ?? [], + AlternatePackageId = deprecation?.AlternatePackage?.PackageId, + License = DescribeLicense(newest), + LicenseUrl = newest.LicenseUrl?.ToString(), + ProjectUrl = newest.ProjectUrl?.ToString(), + Vulnerabilities = vulnerabilities, + }; + } + + private static async Task SafeGetDeprecationAsync( + IPackageSearchMetadata metadata + ) + { + try + { + return await metadata.GetDeprecationMetadataAsync(); + } + catch (Exception) + { + return null; + } + } + + private static string? DescribeLicense(IPackageSearchMetadata metadata) + { + if (metadata.LicenseMetadata is { } license) + { + return string.IsNullOrWhiteSpace(license.License) + ? license.Type.ToString() + : license.License; + } + + return metadata.LicenseUrl?.ToString(); + } + + private static Severity MapSeverity(int severity) => + severity switch + { + >= 3 => Severity.Critical, + 2 => Severity.High, + 1 => Severity.Moderate, + _ => Severity.Low, + }; + + private static List LoadSources(string? rootDirectory) + { + try + { + var settings = Settings.LoadDefaultSettings( + rootDirectory ?? Directory.GetCurrentDirectory() + ); + var provider = new PackageSourceProvider(settings); + return provider.LoadPackageSources().Where(s => s.IsEnabled).ToList(); + } + catch (Exception) + { + return []; + } + } + + public void Dispose() => _cache.Dispose(); +} diff --git a/CentralConfigGenerator.Core/NuGet/PackageGraph.cs b/CentralConfigGenerator.Core/NuGet/PackageGraph.cs new file mode 100644 index 0000000..482b2a7 --- /dev/null +++ b/CentralConfigGenerator.Core/NuGet/PackageGraph.cs @@ -0,0 +1,63 @@ +namespace CentralConfigGenerator.Core.NuGet; + +/// A resolved dependency as reported by dotnet list package. +public sealed record ResolvedPackage +{ + public required string PackageId { get; init; } + + public required string ResolvedVersion { get; init; } + + public string? RequestedVersion { get; init; } + + public required string ProjectPath { get; init; } + + public required string TargetFramework { get; init; } + + public bool IsTransitive { get; init; } +} + +/// The full package graph for a workspace, keyed for quick lookups. +public sealed record PackageGraph +{ + public static readonly PackageGraph Empty = new() + { + Packages = [], + IsAvailable = false, + Error = "Package graph was not collected.", + }; + + public required IReadOnlyList Packages { get; init; } + + public bool IsAvailable { get; init; } + + public string? Error { get; init; } + + public IEnumerable Direct => Packages.Where(p => !p.IsTransitive); + + public IEnumerable Transitive => Packages.Where(p => p.IsTransitive); + + /// Package ids resolved to more than one version across the workspace. + public IEnumerable> DivergentPackages => + Packages + .GroupBy(p => p.PackageId, StringComparer.OrdinalIgnoreCase) + .Where(g => + g.Select(p => p.ResolvedVersion) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Count() > 1 + ); + + /// A stable signature of the resolved graph, used by verify to detect drift. + public IReadOnlyDictionary Signature() => + Packages + .GroupBy( + p => $"{NormalizeProject(p.ProjectPath)}|{p.TargetFramework}|{p.PackageId}", + StringComparer.OrdinalIgnoreCase + ) + .ToDictionary( + g => g.Key, + g => g.OrderBy(p => p.ResolvedVersion, StringComparer.OrdinalIgnoreCase).Last().ResolvedVersion, + StringComparer.OrdinalIgnoreCase + ); + + private static string NormalizeProject(string path) => Path.GetFileName(path); +} diff --git a/CentralConfigGenerator.Core/NuGet/PackageGraphService.cs b/CentralConfigGenerator.Core/NuGet/PackageGraphService.cs new file mode 100644 index 0000000..c65712d --- /dev/null +++ b/CentralConfigGenerator.Core/NuGet/PackageGraphService.cs @@ -0,0 +1,162 @@ +using System.Text.Json; +using CentralConfigGenerator.Core.Services; + +namespace CentralConfigGenerator.Core.NuGet; + +public interface IPackageGraphService +{ + Task LoadAsync( + string target, + string workingDirectory, + bool includeTransitive, + CancellationToken cancellationToken = default + ); +} + +/// +/// Builds the resolved package graph by shelling out to dotnet list package --format json, +/// which is the only source of truth for transitive resolution. +/// +public sealed class PackageGraphService(IDotNetCliService cli) : IPackageGraphService +{ + public async Task LoadAsync( + string target, + string workingDirectory, + bool includeTransitive, + CancellationToken cancellationToken = default + ) + { + if (!cli.IsAvailable) + { + return PackageGraph.Empty with { Error = "The dotnet CLI was not found on PATH." }; + } + + var arguments = $"list \"{target}\" package --format json"; + if (includeTransitive) + { + arguments += " --include-transitive"; + } + + var result = await cli.RunAsync( + arguments, + workingDirectory, + TimeSpan.FromMinutes(5), + cancellationToken + ); + + if (!result.Succeeded) + { + return PackageGraph.Empty with + { + Error = Summarize(result.CombinedOutput), + }; + } + + return Parse(result.StandardOutput); + } + + internal static PackageGraph Parse(string json) + { + var start = json.IndexOf('{'); + if (start < 0) + { + return PackageGraph.Empty with { Error = "dotnet list package returned no JSON." }; + } + + var packages = new List(); + + try + { + using var document = JsonDocument.Parse(json[start..]); + + if (!document.RootElement.TryGetProperty("projects", out var projects)) + { + return PackageGraph.Empty with { Error = "Unexpected dotnet list package output." }; + } + + foreach (var project in projects.EnumerateArray()) + { + var projectPath = project.TryGetProperty("path", out var path) + ? path.GetString() ?? string.Empty + : string.Empty; + + if (!project.TryGetProperty("frameworks", out var frameworks)) + { + continue; + } + + foreach (var framework in frameworks.EnumerateArray()) + { + var tfm = framework.TryGetProperty("framework", out var name) + ? name.GetString() ?? string.Empty + : string.Empty; + + AddPackages(framework, "topLevelPackages", false); + AddPackages(framework, "transitivePackages", true); + + void AddPackages(JsonElement source, string property, bool transitive) + { + if (!source.TryGetProperty(property, out var list)) + { + return; + } + + foreach (var entry in list.EnumerateArray()) + { + var id = entry.TryGetProperty("id", out var idValue) + ? idValue.GetString() + : null; + + var resolved = entry.TryGetProperty("resolvedVersion", out var rv) + ? rv.GetString() + : null; + + if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(resolved)) + { + continue; + } + + packages.Add( + new ResolvedPackage + { + PackageId = id, + ResolvedVersion = resolved, + RequestedVersion = entry.TryGetProperty( + "requestedVersion", + out var requested + ) + ? requested.GetString() + : null, + ProjectPath = projectPath, + TargetFramework = tfm, + IsTransitive = transitive, + } + ); + } + } + } + } + } + catch (JsonException ex) + { + return PackageGraph.Empty with { Error = ex.Message }; + } + + return new PackageGraph { Packages = packages, IsAvailable = true }; + } + + private static string Summarize(string output) + { + var lines = output + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(l => l.Trim()) + .Where(l => l.Length > 0) + .ToList(); + + var error = lines.FirstOrDefault(l => + l.Contains("error", StringComparison.OrdinalIgnoreCase) + ); + + return error ?? (lines.Count > 0 ? lines[^1] : "dotnet list package failed."); + } +} diff --git a/CentralConfigGenerator.Core/NuGet/PackageMetadataSnapshot.cs b/CentralConfigGenerator.Core/NuGet/PackageMetadataSnapshot.cs new file mode 100644 index 0000000..c979de9 --- /dev/null +++ b/CentralConfigGenerator.Core/NuGet/PackageMetadataSnapshot.cs @@ -0,0 +1,55 @@ +using CentralConfigGenerator.Core.Analysis; +using NuGetVersioning = NuGet.Versioning; + +namespace CentralConfigGenerator.Core.NuGet; + +/// Everything the analyzers need to know about a package, fetched once per run. +public sealed record PackageMetadataSnapshot +{ + public required string PackageId { get; init; } + + public bool Found { get; init; } + + public IReadOnlyList AllVersions { get; init; } = []; + + public NuGetVersioning.NuGetVersion? LatestStable { get; init; } + + public NuGetVersioning.NuGetVersion? LatestIncludingPrerelease { get; init; } + + public bool IsDeprecated { get; init; } + + public string? DeprecationMessage { get; init; } + + public IReadOnlyList DeprecationReasons { get; init; } = []; + + public string? AlternatePackageId { get; init; } + + public string? License { get; init; } + + public string? LicenseUrl { get; init; } + + public string? ProjectUrl { get; init; } + + public IReadOnlyList Vulnerabilities { get; init; } = []; + + /// Set when the feed could not be reached; the run is then reported as incomplete. + public string? Error { get; init; } + + public static PackageMetadataSnapshot Unavailable(string packageId, string error) => + new() + { + PackageId = packageId, + Found = false, + Error = error, + }; +} + +public sealed record PackageVulnerability +{ + public required Severity Severity { get; init; } + + public required string AdvisoryUrl { get; init; } + + /// Version range the advisory applies to, when the feed reports one. + public string? AffectedRange { get; init; } +} diff --git a/CentralConfigGenerator.Core/Reporting/CsvReportWriter.cs b/CentralConfigGenerator.Core/Reporting/CsvReportWriter.cs new file mode 100644 index 0000000..fe935c4 --- /dev/null +++ b/CentralConfigGenerator.Core/Reporting/CsvReportWriter.cs @@ -0,0 +1,43 @@ +using System.Text; +using CentralConfigGenerator.Core.Analysis; + +namespace CentralConfigGenerator.Core.Reporting; + +/// One row per finding, for spreadsheets and ad-hoc pivots. +public sealed class CsvReportWriter : IReportWriter +{ + public ReportFormat Format => ReportFormat.Csv; + + public string Write(AnalysisReport report) + { + var builder = new StringBuilder(); + builder.Append("RuleId,Severity,PackageId,Version,Project,Message,Recommendation,AutoFixable,Fingerprint\n"); + + foreach (var finding in report.Findings) + { + builder + .Append(Quote(finding.RuleId)).Append(',') + .Append(Quote(finding.Severity.ToString())).Append(',') + .Append(Quote(finding.PackageId)).Append(',') + .Append(Quote(finding.Version)).Append(',') + .Append(Quote(ReportPaths.Relative(finding.ProjectPath, report.RootDirectory))).Append(',') + .Append(Quote(finding.Message)).Append(',') + .Append(Quote(finding.Recommendation)).Append(',') + .Append(finding.IsAutoFixable ? "true" : "false").Append(',') + .Append(Quote(finding.Fingerprint)).Append('\n'); + } + + return builder.ToString(); + } + + private static string Quote(string? value) + { + if (string.IsNullOrEmpty(value)) + { + return string.Empty; + } + + var escaped = value.Replace("\"", "\"\"").Replace("\r", " ").Replace("\n", " "); + return $"\"{escaped}\""; + } +} diff --git a/CentralConfigGenerator.Core/Reporting/IReportWriter.cs b/CentralConfigGenerator.Core/Reporting/IReportWriter.cs new file mode 100644 index 0000000..0d261bf --- /dev/null +++ b/CentralConfigGenerator.Core/Reporting/IReportWriter.cs @@ -0,0 +1,23 @@ +using CentralConfigGenerator.Core.Analysis; + +namespace CentralConfigGenerator.Core.Reporting; + +public interface IReportWriter +{ + ReportFormat Format { get; } + + string Write(AnalysisReport report); +} + +public static class ReportWriterFactory +{ + public static IReportWriter Create(ReportFormat format) => + format switch + { + ReportFormat.Json => new JsonReportWriter(), + ReportFormat.Sarif => new SarifReportWriter(), + ReportFormat.Markdown => new MarkdownReportWriter(), + ReportFormat.Csv => new CsvReportWriter(), + _ => new JsonReportWriter(), + }; +} diff --git a/CentralConfigGenerator.Core/Reporting/JsonReportWriter.cs b/CentralConfigGenerator.Core/Reporting/JsonReportWriter.cs new file mode 100644 index 0000000..31f825b --- /dev/null +++ b/CentralConfigGenerator.Core/Reporting/JsonReportWriter.cs @@ -0,0 +1,69 @@ +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; +using CentralConfigGenerator.Core.Analysis; + +namespace CentralConfigGenerator.Core.Reporting; + +/// Machine-readable report; the shape is stable and safe to script against. +public sealed class JsonReportWriter : IReportWriter +{ + private static readonly JsonSerializerOptions Options = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + Converters = { new JsonStringEnumConverter() }, + }; + + public ReportFormat Format => ReportFormat.Json; + + public string Write(AnalysisReport report) + { + var payload = new + { + success = report.HighestSeverity < Severity.High, + summary = new + { + healthScore = report.HealthScore, + grade = report.Grade, + projectCount = report.ProjectCount, + packageCount = report.PackageCount, + findingCount = report.Findings.Count, + suppressedCount = report.SuppressedCount, + durationMs = (long)report.Duration.TotalMilliseconds, + incomplete = report.IsIncomplete, + bySeverity = new + { + critical = report.CountOf(Severity.Critical), + high = report.CountOf(Severity.High), + moderate = report.CountOf(Severity.Moderate), + low = report.CountOf(Severity.Low), + info = report.CountOf(Severity.Info), + }, + byRule = report + .Findings.GroupBy(f => f.RuleId) + .OrderByDescending(g => g.Count()) + .ToDictionary(g => g.Key, g => g.Count()), + }, + incompletions = report.Incompletions, + findings = report + .Findings.Select(f => new + { + ruleId = f.RuleId, + severity = f.Severity.ToString(), + message = f.Message, + packageId = f.PackageId, + version = f.Version, + project = ReportPaths.Relative(f.ProjectPath, report.RootDirectory), + recommendation = f.Recommendation, + autoFixable = f.IsAutoFixable, + fingerprint = f.Fingerprint, + }) + .ToList(), + }; + + return JsonSerializer.Serialize(payload, Options); + } +} diff --git a/CentralConfigGenerator.Core/Reporting/MarkdownReportWriter.cs b/CentralConfigGenerator.Core/Reporting/MarkdownReportWriter.cs new file mode 100644 index 0000000..dae9e61 --- /dev/null +++ b/CentralConfigGenerator.Core/Reporting/MarkdownReportWriter.cs @@ -0,0 +1,113 @@ +using System.Text; +using CentralConfigGenerator.Core.Analysis; + +namespace CentralConfigGenerator.Core.Reporting; + +/// Verdict-first Markdown, sized for a GitHub step summary or a PR comment. +public sealed class MarkdownReportWriter : IReportWriter +{ + public ReportFormat Format => ReportFormat.Markdown; + + public string Write(AnalysisReport report) + { + var builder = new StringBuilder(); + var verdict = report.HighestSeverity switch + { + Severity.Critical => "❌ Critical issues found", + Severity.High => "❌ High severity issues found", + Severity.Moderate => "⚠️ Moderate issues found", + Severity.Low or Severity.Info => "✅ Minor findings only", + _ => "✅ No issues found", + }; + + builder.Append("## Dependency health: ").Append(report.HealthScore).Append("/100 (") + .Append(report.Grade).Append(")\n\n"); + builder.Append(verdict).Append("\n\n"); + + builder.Append("| Metric | Value |\n|---|---|\n"); + builder.Append("| Projects | ").Append(report.ProjectCount).Append(" |\n"); + builder.Append("| Packages | ").Append(report.PackageCount).Append(" |\n"); + builder.Append("| Findings | ").Append(report.Findings.Count).Append(" |\n"); + + if (report.SuppressedCount > 0) + { + builder.Append("| Suppressed by baseline | ").Append(report.SuppressedCount).Append(" |\n"); + } + + builder.Append("| Duration | ").Append($"{report.Duration.TotalSeconds:F1}s").Append(" |\n\n"); + + if (report.Findings.Count == 0) + { + builder.Append("No findings. 🎉\n"); + return builder.ToString(); + } + + builder.Append("| Severity | Count |\n|---|---|\n"); + foreach (var severity in new[] { Severity.Critical, Severity.High, Severity.Moderate, Severity.Low, Severity.Info }) + { + var count = report.CountOf(severity); + if (count > 0) + { + builder.Append("| ").Append(Icon(severity)).Append(' ').Append(severity) + .Append(" | ").Append(count).Append(" |\n"); + } + } + + builder.Append('\n'); + + foreach (var group in report.Findings.GroupBy(f => f.Severity).OrderByDescending(g => g.Key)) + { + builder.Append("= Severity.High) + { + builder.Append(" open"); + } + + builder.Append(">").Append(Icon(group.Key)).Append(' ').Append(group.Key) + .Append(" (").Append(group.Count()).Append(")\n\n"); + + foreach (var finding in group) + { + builder.Append("- **").Append(finding.RuleId).Append("** — ") + .Append(Escape(finding.Message)); + + if (finding.Recommendation is not null) + { + builder.Append(' ').Append(Escape(finding.Recommendation)); + } + + if (finding.ProjectPath is not null) + { + builder.Append(" `").Append(Path.GetFileName(finding.ProjectPath)).Append('`'); + } + + builder.Append('\n'); + } + + builder.Append("\n\n\n"); + } + + if (report.IsIncomplete) + { + builder.Append("> ⚠️ The scan did not complete fully:\n"); + foreach (var incompletion in report.Incompletions) + { + builder.Append("> - ").Append(Escape(incompletion)).Append('\n'); + } + } + + return builder.ToString(); + } + + private static string Icon(Severity severity) => + severity switch + { + Severity.Critical => "🔴", + Severity.High => "🟠", + Severity.Moderate => "🟡", + Severity.Low => "🔵", + _ => "⚪", + }; + + private static string Escape(string value) => value.Replace("|", "\\|"); +} diff --git a/CentralConfigGenerator.Core/Reporting/ReportFormat.cs b/CentralConfigGenerator.Core/Reporting/ReportFormat.cs new file mode 100644 index 0000000..9da4b21 --- /dev/null +++ b/CentralConfigGenerator.Core/Reporting/ReportFormat.cs @@ -0,0 +1,11 @@ +namespace CentralConfigGenerator.Core.Reporting; + +/// Output formats supported by every reporting command. +public enum ReportFormat +{ + Terminal, + Json, + Sarif, + Markdown, + Csv, +} diff --git a/CentralConfigGenerator.Core/Reporting/ReportPaths.cs b/CentralConfigGenerator.Core/Reporting/ReportPaths.cs new file mode 100644 index 0000000..fecf529 --- /dev/null +++ b/CentralConfigGenerator.Core/Reporting/ReportPaths.cs @@ -0,0 +1,34 @@ +namespace CentralConfigGenerator.Core.Reporting; + +/// Path formatting shared by the report writers. +public static class ReportPaths +{ + /// + /// Renders a path relative to the scanned root with forward slashes, so reports are stable + /// across machines and diff cleanly in CI. + /// + public static string? Relative(string? path, string? root) + { + if (string.IsNullOrEmpty(path)) + { + return null; + } + + if (string.IsNullOrEmpty(root)) + { + return path; + } + + try + { + var relative = Path.GetRelativePath(root, path); + return relative.StartsWith("..", StringComparison.Ordinal) + ? path + : relative.Replace(Path.DirectorySeparatorChar, '/'); + } + catch (ArgumentException) + { + return path; + } + } +} diff --git a/CentralConfigGenerator.Core/Reporting/SarifReportWriter.cs b/CentralConfigGenerator.Core/Reporting/SarifReportWriter.cs new file mode 100644 index 0000000..018151b --- /dev/null +++ b/CentralConfigGenerator.Core/Reporting/SarifReportWriter.cs @@ -0,0 +1,111 @@ +using System.Text.Encodings.Web; +using System.Text.Json; +using CentralConfigGenerator.Core.Analysis; + +namespace CentralConfigGenerator.Core.Reporting; + +/// +/// SARIF 2.1.0 output, so findings render as inline annotations when uploaded with +/// github/codeql-action/upload-sarif. +/// +public sealed class SarifReportWriter : IReportWriter +{ + private static readonly JsonSerializerOptions Options = new() + { + WriteIndented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + + public ReportFormat Format => ReportFormat.Sarif; + + public string Write(AnalysisReport report) + { + var usedRules = report + .Findings.Select(f => f.RuleId) + .Distinct(StringComparer.Ordinal) + .Select(RuleCatalog.Find) + .Where(r => r is not null) + .Select(r => new + { + id = r!.RuleId, + name = r.Title, + shortDescription = new { text = r.Title }, + fullDescription = new { text = r.Description }, + help = new { text = r.Rationale, markdown = $"**{r.Title}**\n\n{r.Description}\n\n{r.Rationale}" }, + defaultConfiguration = new { level = ToSarifLevel(r.DefaultSeverity) }, + properties = new { tags = new[] { "dependencies", "nuget" } }, + }) + .ToList(); + + var payload = new + { + version = "2.1.0", + schema = "https://json.schemastore.org/sarif-2.1.0.json", + runs = new[] + { + new + { + tool = new + { + driver = new + { + name = "CentralConfigGenerator", + informationUri = "https://github.com/TarasKovalenko/CentralConfigGenerator", + version = ToolVersion, + rules = usedRules, + }, + }, + results = report + .Findings.Select(f => new + { + ruleId = f.RuleId, + level = ToSarifLevel(f.Severity), + message = new + { + text = f.Recommendation is null + ? f.Message + : $"{f.Message} {f.Recommendation}", + }, + partialFingerprints = new { centralConfig = f.Fingerprint }, + locations = new[] + { + new + { + physicalLocation = new + { + artifactLocation = new + { + uri = ToUri(f.ProjectPath, report.RootDirectory), + }, + region = new { startLine = Math.Max(1, f.Line) }, + }, + }, + }, + }) + .ToList(), + }, + }, + }; + + // The SARIF schema property is "$schema", which C# cannot express as an identifier. + return JsonSerializer.Serialize(payload, Options).Replace("\"schema\":", "\"$schema\":"); + } + + private static string ToolVersion => + typeof(SarifReportWriter).Assembly.GetName().Version?.ToString(3) ?? "1.0.0"; + + private static string ToSarifLevel(Severity severity) => + severity switch + { + Severity.Critical or Severity.High => "error", + Severity.Moderate => "warning", + Severity.Low => "note", + _ => "note", + }; + + private static string ToUri(string? path, string root) => + ReportPaths.Relative( + path, + string.IsNullOrWhiteSpace(root) ? Directory.GetCurrentDirectory() : root + ) ?? "Directory.Packages.props"; +} diff --git a/CentralConfigGenerator.Core/Services/Abstractions/IBackupService.cs b/CentralConfigGenerator.Core/Services/Abstractions/IBackupService.cs new file mode 100644 index 0000000..5cb6943 --- /dev/null +++ b/CentralConfigGenerator.Core/Services/Abstractions/IBackupService.cs @@ -0,0 +1,37 @@ +using CentralConfigGenerator.Core.Models; + +namespace CentralConfigGenerator.Core.Services.Abstractions; + +public interface IBackupService +{ + Task CreateAsync( + string rootDirectory, + string backupDirectory, + string operation, + IEnumerable files, + CancellationToken cancellationToken = default + ); + + IReadOnlyList List(string backupDirectory); + + Task RestoreLatestAsync( + string backupDirectory, + CancellationToken cancellationToken = default + ); + + Task RestoreAsync( + string backupDirectory, + string backupId, + CancellationToken cancellationToken = default + ); + + IReadOnlyList Prune(string backupDirectory, int retention); + + IReadOnlyList PruneAll(string backupDirectory); + + Task EnsureGitIgnoreAsync( + string gitIgnoreDirectory, + string backupDirectoryName, + CancellationToken cancellationToken = default + ); +} diff --git a/CentralConfigGenerator.Core/Services/BackupService.cs b/CentralConfigGenerator.Core/Services/BackupService.cs new file mode 100644 index 0000000..b584107 --- /dev/null +++ b/CentralConfigGenerator.Core/Services/BackupService.cs @@ -0,0 +1,275 @@ +using System.Text.Json; +using CentralConfigGenerator.Core.IO; +using CentralConfigGenerator.Core.Models; +using CentralConfigGenerator.Core.Services.Abstractions; + +namespace CentralConfigGenerator.Core.Services; + +/// +/// Creates timestamped backups of every file an operation is about to touch, and restores them +/// on demand. Backups live under .centralconfig-backups/<timestamp>. +/// +public sealed class BackupService(IFileSystem fileSystem) : IBackupService +{ + public const string DefaultBackupFolderName = ".centralconfig-backups"; + private const string ManifestFileName = "manifest.json"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + public async Task CreateAsync( + string rootDirectory, + string backupDirectory, + string operation, + IEnumerable files, + CancellationToken cancellationToken = default + ) + { + var paths = files.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + if (paths.Count == 0) + { + return null; + } + + var id = DateTimeOffset.UtcNow.ToString("yyyyMMdd-HHmmss-fff"); + var root = ResolveRoot(backupDirectory); + var target = Path.Combine(root, id); + fileSystem.CreateDirectory(target); + + var manifest = new BackupManifest + { + Id = id, + CreatedAt = DateTimeOffset.UtcNow, + Operation = operation, + RootDirectory = Path.GetFullPath(rootDirectory), + }; + + foreach (var path in paths) + { + cancellationToken.ThrowIfCancellationRequested(); + + var full = Path.GetFullPath(path); + var relative = MakeRelative(manifest.RootDirectory, full); + var destination = Path.Combine(target, relative); + + if (fileSystem.FileExists(full)) + { + fileSystem.CopyFile(full, destination, overwrite: true); + manifest.Entries.Add( + new BackupEntry { OriginalPath = full, RelativePath = relative } + ); + } + else + { + manifest.Entries.Add( + new BackupEntry + { + OriginalPath = full, + RelativePath = relative, + WasCreated = true, + } + ); + } + } + + await fileSystem.WriteAllTextAsync( + Path.Combine(target, ManifestFileName), + JsonSerializer.Serialize(manifest, JsonOptions), + cancellationToken + ); + + return ToBackupSet(manifest, target); + } + + public IReadOnlyList List(string backupDirectory) + { + var root = ResolveRoot(backupDirectory); + if (!fileSystem.DirectoryExists(root)) + { + return []; + } + + var results = new List(); + + foreach (var directory in fileSystem.EnumerateDirectories(root)) + { + var manifest = ReadManifest(directory); + if (manifest is not null) + { + results.Add(ToBackupSet(manifest, directory)); + } + } + + return results.OrderByDescending(b => b.CreatedAt).ToList(); + } + + public Task RestoreLatestAsync( + string backupDirectory, + CancellationToken cancellationToken = default + ) + { + var latest = List(backupDirectory).FirstOrDefault(); + return latest is null + ? Task.FromResult(null) + : RestoreAsync(backupDirectory, latest.Id, cancellationToken); + } + + public async Task RestoreAsync( + string backupDirectory, + string backupId, + CancellationToken cancellationToken = default + ) + { + var root = ResolveRoot(backupDirectory); + var target = Path.Combine(root, backupId); + var manifest = ReadManifest(target); + + if (manifest is null) + { + return null; + } + + foreach (var entry in manifest.Entries) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (entry.WasCreated) + { + if (fileSystem.FileExists(entry.OriginalPath)) + { + fileSystem.DeleteFile(entry.OriginalPath); + } + + continue; + } + + var source = Path.Combine(target, entry.RelativePath); + if (fileSystem.FileExists(source)) + { + fileSystem.CopyFile(source, entry.OriginalPath, overwrite: true); + } + } + + await Task.CompletedTask; + return ToBackupSet(manifest, target); + } + + public IReadOnlyList Prune(string backupDirectory, int retention) + { + if (retention < 0) + { + retention = 0; + } + + var all = List(backupDirectory); + var removed = all.Skip(retention).ToList(); + + foreach (var backup in removed) + { + fileSystem.DeleteDirectory(backup.Path, recursive: true); + } + + return removed; + } + + public IReadOnlyList PruneAll(string backupDirectory) => Prune(backupDirectory, 0); + + public async Task EnsureGitIgnoreAsync( + string gitIgnoreDirectory, + string backupDirectoryName, + CancellationToken cancellationToken = default + ) + { + var path = Path.Combine(gitIgnoreDirectory, ".gitignore"); + var entry = backupDirectoryName.TrimEnd('/') + "/"; + + var existing = fileSystem.FileExists(path) + ? await fileSystem.ReadAllTextAsync(path, cancellationToken) + : string.Empty; + + var lines = existing.Replace("\r\n", "\n").Split('\n'); + if (lines.Any(l => string.Equals(l.Trim(), entry, StringComparison.OrdinalIgnoreCase))) + { + return; + } + + var separator = existing.Length == 0 || existing.EndsWith('\n') ? string.Empty : "\n"; + var content = + $"{existing}{separator}\n# CentralConfigGenerator backups\n{entry}\n"; + + await fileSystem.WriteAllTextAsync(path, content, cancellationToken); + } + + private static string ResolveRoot(string backupDirectory) + { + var full = Path.GetFullPath(backupDirectory); + return string.Equals( + Path.GetFileName(full), + DefaultBackupFolderName, + StringComparison.OrdinalIgnoreCase + ) + ? full + : Path.Combine(full, DefaultBackupFolderName); + } + + private static string MakeRelative(string root, string path) + { + var relative = Path.GetRelativePath(root, path); + + // Files outside the scanned root are flattened so they still land inside the backup. + if (relative.StartsWith("..", StringComparison.Ordinal) || Path.IsPathRooted(relative)) + { + var sanitized = path.Replace(Path.VolumeSeparatorChar, '_') + .Replace(Path.DirectorySeparatorChar, '_') + .Replace(Path.AltDirectorySeparatorChar, '_'); + return Path.Combine("_external", sanitized); + } + + return relative; + } + + private BackupManifest? ReadManifest(string directory) + { + var manifestPath = Path.Combine(directory, ManifestFileName); + if (!fileSystem.FileExists(manifestPath)) + { + return null; + } + + try + { + var json = fileSystem.ReadAllTextAsync(manifestPath).GetAwaiter().GetResult(); + return JsonSerializer.Deserialize(json, JsonOptions); + } + catch (Exception ex) when (ex is JsonException or IOException) + { + return null; + } + } + + private BackupSet ToBackupSet(BackupManifest manifest, string path) + { + long size = 0; + foreach (var entry in manifest.Entries.Where(e => !e.WasCreated)) + { + var file = Path.Combine(path, entry.RelativePath); + if (fileSystem.FileExists(file)) + { + size += fileSystem.GetFileSize(file); + } + } + + return new BackupSet + { + Id = manifest.Id, + Path = path, + CreatedAt = manifest.CreatedAt, + Operation = manifest.Operation, + Files = manifest.Entries.Select(e => e.OriginalPath).ToList(), + SizeInBytes = size, + }; + } +} diff --git a/CentralConfigGenerator.Core/Services/DotNetCliService.cs b/CentralConfigGenerator.Core/Services/DotNetCliService.cs new file mode 100644 index 0000000..9fe1aa3 --- /dev/null +++ b/CentralConfigGenerator.Core/Services/DotNetCliService.cs @@ -0,0 +1,245 @@ +using System.Diagnostics; +using System.Text; + +namespace CentralConfigGenerator.Core.Services; + +/// Result of running a dotnet sub-command. +public sealed record ProcessResult +{ + public required int ExitCode { get; init; } + + public required string StandardOutput { get; init; } + + public required string StandardError { get; init; } + + public required TimeSpan Duration { get; init; } + + public bool Succeeded => ExitCode == 0; + + public string CombinedOutput => + string.IsNullOrWhiteSpace(StandardError) + ? StandardOutput + : StandardOutput + Environment.NewLine + StandardError; +} + +public interface IDotNetCliService +{ + bool IsAvailable { get; } + + Task RunAsync( + string arguments, + string workingDirectory, + TimeSpan? timeout = null, + CancellationToken cancellationToken = default + ); + + Task RestoreAsync( + string target, + string workingDirectory, + CancellationToken cancellationToken = default + ); + + Task BuildAsync( + string target, + string workingDirectory, + CancellationToken cancellationToken = default + ); + + Task TestAsync( + string target, + string workingDirectory, + string? filter = null, + CancellationToken cancellationToken = default + ); +} + +/// Runs the local dotnet CLI, used for restores, package graphs, builds and tests. +public sealed class DotNetCliService : IDotNetCliService +{ + private bool? _available; + + public bool IsAvailable + { + get + { + _available ??= Probe(); + return _available.Value; + } + } + + public async Task RunAsync( + string arguments, + string workingDirectory, + TimeSpan? timeout = null, + CancellationToken cancellationToken = default + ) + { + var stopwatch = Stopwatch.StartNew(); + + var startInfo = new ProcessStartInfo("dotnet") + { + Arguments = arguments, + WorkingDirectory = Directory.Exists(workingDirectory) + ? workingDirectory + : Directory.GetCurrentDirectory(), + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + // Keep output machine-parsable and free of MSBuild's terminal logger escape codes. + startInfo.Environment["DOTNET_CLI_UI_LANGUAGE"] = "en"; + startInfo.Environment["DOTNET_NOLOGO"] = "1"; + startInfo.Environment["TERM"] = "dumb"; + + using var process = new Process { StartInfo = startInfo }; + var stdout = new StringBuilder(); + var stderr = new StringBuilder(); + + process.OutputDataReceived += (_, e) => + { + if (e.Data is not null) + { + stdout.AppendLine(e.Data); + } + }; + + process.ErrorDataReceived += (_, e) => + { + if (e.Data is not null) + { + stderr.AppendLine(e.Data); + } + }; + + try + { + process.Start(); + } + catch (Exception ex) + { + return new ProcessResult + { + ExitCode = -1, + StandardOutput = string.Empty, + StandardError = ex.Message, + Duration = stopwatch.Elapsed, + }; + } + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + using var timeoutSource = timeout.HasValue + ? new CancellationTokenSource(timeout.Value) + : new CancellationTokenSource(); + + using var linked = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + timeoutSource.Token + ); + + try + { + await process.WaitForExitAsync(linked.Token); + } + catch (OperationCanceledException) + { + TryKill(process); + + return new ProcessResult + { + ExitCode = -1, + StandardOutput = stdout.ToString(), + StandardError = stderr.ToString() + Environment.NewLine + "Process timed out.", + Duration = stopwatch.Elapsed, + }; + } + + return new ProcessResult + { + ExitCode = process.ExitCode, + StandardOutput = stdout.ToString(), + StandardError = stderr.ToString(), + Duration = stopwatch.Elapsed, + }; + } + + public Task RestoreAsync( + string target, + string workingDirectory, + CancellationToken cancellationToken = default + ) => RunAsync($"restore \"{target}\"", workingDirectory, TimeSpan.FromMinutes(10), cancellationToken); + + public Task BuildAsync( + string target, + string workingDirectory, + CancellationToken cancellationToken = default + ) => + RunAsync( + $"build \"{target}\" --nologo -v quiet", + workingDirectory, + TimeSpan.FromMinutes(15), + cancellationToken + ); + + public Task TestAsync( + string target, + string workingDirectory, + string? filter = null, + CancellationToken cancellationToken = default + ) + { + var arguments = $"test \"{target}\" --nologo -v quiet"; + if (!string.IsNullOrWhiteSpace(filter)) + { + arguments += $" --filter \"{filter}\""; + } + + return RunAsync(arguments, workingDirectory, TimeSpan.FromMinutes(30), cancellationToken); + } + + private static void TryKill(Process process) + { + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } + catch (Exception) + { + // The process already exited or cannot be killed; nothing useful to do. + } + } + + private static bool Probe() + { + try + { + using var process = Process.Start( + new ProcessStartInfo("dotnet", "--version") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + } + ); + + if (process is null) + { + return false; + } + + process.WaitForExit(10_000); + return process.HasExited && process.ExitCode == 0; + } + catch (Exception) + { + return false; + } + } +} diff --git a/CentralConfigGenerator.Core/Services/UnifiedDiff.cs b/CentralConfigGenerator.Core/Services/UnifiedDiff.cs new file mode 100644 index 0000000..404c4f7 --- /dev/null +++ b/CentralConfigGenerator.Core/Services/UnifiedDiff.cs @@ -0,0 +1,203 @@ +using System.Text; + +namespace CentralConfigGenerator.Core.Services; + +/// +/// Minimal unified-diff generator (Myers-style LCS) used by --dry-run --diff. +/// +public static class UnifiedDiff +{ + public static string Create( + string oldText, + string newText, + string oldLabel, + string newLabel, + int context = 3 + ) + { + var oldLines = SplitLines(oldText); + var newLines = SplitLines(newText); + var hunks = BuildHunks(oldLines, newLines, context); + + if (hunks.Count == 0) + { + return string.Empty; + } + + var builder = new StringBuilder(); + builder.Append("--- ").Append(oldLabel).Append('\n'); + builder.Append("+++ ").Append(newLabel).Append('\n'); + + foreach (var hunk in hunks) + { + builder + .Append("@@ -") + .Append(hunk.OldStart + 1) + .Append(',') + .Append(hunk.OldCount) + .Append(" +") + .Append(hunk.NewStart + 1) + .Append(',') + .Append(hunk.NewCount) + .Append(" @@\n"); + + foreach (var line in hunk.Lines) + { + builder.Append(line).Append('\n'); + } + } + + return builder.ToString(); + } + + private static string[] SplitLines(string text) => + text.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n'); + + private sealed record Hunk(int OldStart, int OldCount, int NewStart, int NewCount, List Lines); + + private static List BuildHunks(string[] oldLines, string[] newLines, int context) + { + var operations = Diff(oldLines, newLines); + var hunks = new List(); + + var index = 0; + while (index < operations.Count) + { + if (operations[index].Kind == OpKind.Equal) + { + index++; + continue; + } + + var start = Math.Max(0, index - context); + var end = index; + while (end < operations.Count) + { + if (operations[end].Kind != OpKind.Equal) + { + end++; + continue; + } + + // Extend across short runs of equal lines so nearby edits share a hunk. + var runEnd = end; + while (runEnd < operations.Count && operations[runEnd].Kind == OpKind.Equal) + { + runEnd++; + } + + if (runEnd - end > context * 2 || runEnd == operations.Count) + { + end = Math.Min(operations.Count, end + context); + break; + } + + end = runEnd; + } + + var lines = new List(); + int oldStart = -1, + newStart = -1, + oldCount = 0, + newCount = 0; + + for (var i = start; i < end; i++) + { + var op = operations[i]; + oldStart = oldStart < 0 && op.OldIndex >= 0 ? op.OldIndex : oldStart; + newStart = newStart < 0 && op.NewIndex >= 0 ? op.NewIndex : newStart; + + switch (op.Kind) + { + case OpKind.Equal: + lines.Add(" " + op.Text); + oldCount++; + newCount++; + break; + case OpKind.Delete: + lines.Add("-" + op.Text); + oldCount++; + break; + case OpKind.Insert: + lines.Add("+" + op.Text); + newCount++; + break; + } + } + + hunks.Add( + new Hunk( + Math.Max(0, oldStart), + oldCount, + Math.Max(0, newStart), + newCount, + lines + ) + ); + index = end; + } + + return hunks; + } + + private enum OpKind + { + Equal, + Delete, + Insert, + } + + private sealed record Op(OpKind Kind, string Text, int OldIndex, int NewIndex); + + private static List Diff(string[] a, string[] b) + { + var lengths = new int[a.Length + 1, b.Length + 1]; + for (var i = a.Length - 1; i >= 0; i--) + { + for (var j = b.Length - 1; j >= 0; j--) + { + lengths[i, j] = a[i] == b[j] + ? lengths[i + 1, j + 1] + 1 + : Math.Max(lengths[i + 1, j], lengths[i, j + 1]); + } + } + + var operations = new List(); + int x = 0, + y = 0; + + while (x < a.Length && y < b.Length) + { + if (a[x] == b[y]) + { + operations.Add(new Op(OpKind.Equal, a[x], x, y)); + x++; + y++; + } + else if (lengths[x + 1, y] >= lengths[x, y + 1]) + { + operations.Add(new Op(OpKind.Delete, a[x], x, -1)); + x++; + } + else + { + operations.Add(new Op(OpKind.Insert, b[y], -1, y)); + y++; + } + } + + while (x < a.Length) + { + operations.Add(new Op(OpKind.Delete, a[x], x, -1)); + x++; + } + + while (y < b.Length) + { + operations.Add(new Op(OpKind.Insert, b[y], -1, y)); + y++; + } + + return operations; + } +} diff --git a/CentralConfigGenerator.Core/Services/VersionConflictException.cs b/CentralConfigGenerator.Core/Services/VersionConflictException.cs new file mode 100644 index 0000000..a675b53 --- /dev/null +++ b/CentralConfigGenerator.Core/Services/VersionConflictException.cs @@ -0,0 +1,16 @@ +namespace CentralConfigGenerator.Core.Services; + +/// +/// Thrown when projects disagree on a package version and the caller asked for the +/// strategy. +/// +public sealed class VersionConflictException(string packageId, IReadOnlyList versions) + : Exception( + $"Version conflict for '{packageId}': {string.Join(", ", versions)}. " + + "Resolve it manually or choose a different conflict strategy." + ) +{ + public string PackageId { get; } = packageId; + + public IReadOnlyList Versions { get; } = versions; +} diff --git a/CentralConfigGenerator.Core/Services/VersionConflictResolver.cs b/CentralConfigGenerator.Core/Services/VersionConflictResolver.cs index 9ad5eb8..b7429b8 100644 --- a/CentralConfigGenerator.Core/Services/VersionConflictResolver.cs +++ b/CentralConfigGenerator.Core/Services/VersionConflictResolver.cs @@ -6,10 +6,20 @@ namespace CentralConfigGenerator.Core.Services; public enum VersionResolutionStrategy { + /// Pick the highest version any project asks for. The safe default. Highest, + + /// Pick the lowest version, keeping the migration as conservative as possible. Lowest, + + /// Pick whichever version the most projects already use. MostCommon, + + /// Refuse to guess and let the caller decide. Manual, + + /// Abort the run when projects disagree. + Fail, } public class VersionConflictResolver : IVersionConflictResolver @@ -50,6 +60,10 @@ VersionResolutionStrategy strategy VersionResolutionStrategy.Manual => throw new InvalidOperationException( $"Manual resolution required for package '{packageName}'. Versions found: {string.Join(", ", versionList)}" ), + VersionResolutionStrategy.Fail => throw new VersionConflictException( + packageName, + versionList + ), _ => throw new ArgumentOutOfRangeException(nameof(strategy)), }; } diff --git a/CentralConfigGenerator.Core/Services/VersionSelector.cs b/CentralConfigGenerator.Core/Services/VersionSelector.cs new file mode 100644 index 0000000..4dbd49b --- /dev/null +++ b/CentralConfigGenerator.Core/Services/VersionSelector.cs @@ -0,0 +1,90 @@ +using CentralConfigGenerator.Core.Services.Abstractions; +using NuGet.Versioning; + +namespace CentralConfigGenerator.Core.Services; + +/// +/// Chooses a single version from the set a package is referenced with, applying pre-release +/// filtering and the caller's conflict strategy. +/// +public sealed class VersionSelector(IVersionConflictResolver resolver) +{ + public string Select( + string packageId, + IReadOnlyList versions, + VersionResolutionStrategy strategy, + bool ignorePrerelease, + VersionComparison comparison = VersionComparison.VersionRelease + ) + { + var candidates = versions + .Where(v => !string.IsNullOrWhiteSpace(v)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (candidates.Count == 0) + { + throw new ArgumentException( + $"No versions supplied for package '{packageId}'.", + nameof(versions) + ); + } + + if (ignorePrerelease) + { + var stable = candidates.Where(v => !IsPrerelease(v)).ToList(); + + // Only drop pre-releases when at least one stable version remains. + if (stable.Count > 0) + { + candidates = stable; + } + } + + if (candidates.Count == 1) + { + return candidates[0]; + } + + // Highest/Lowest are the only strategies where the comparison scope changes the answer: + // it decides whether pre-release tags and build metadata participate in the ordering. + if (strategy is VersionResolutionStrategy.Highest or VersionResolutionStrategy.Lowest) + { + var ordered = candidates + .Where(v => NuGetVersion.TryParse(v, out _)) + .OrderBy(v => v, Comparer.Create((a, b) => Compare(a, b, comparison))) + .ToList(); + + if (ordered.Count > 0) + { + return strategy == VersionResolutionStrategy.Highest ? ordered[^1] : ordered[0]; + } + } + + return resolver.Resolve(packageId, candidates, strategy); + } + + /// Compares two version strings using the requested NuGet comparison scope. + public static int Compare(string left, string right, VersionComparison comparison) + { + var hasLeft = NuGetVersion.TryParse(left, out var leftVersion); + var hasRight = NuGetVersion.TryParse(right, out var rightVersion); + + if (hasLeft && hasRight) + { + return new VersionComparer(comparison).Compare(leftVersion, rightVersion); + } + + return string.Compare(left, right, StringComparison.OrdinalIgnoreCase); + } + + public static bool IsPrerelease(string version) => + NuGetVersion.TryParse(version, out var parsed) && parsed.IsPrerelease; + + public static bool IsFloating(string version) => + version.Contains('*', StringComparison.Ordinal) + || ( + VersionRange.TryParse(version, out var range) + && (range.IsFloating || !range.HasUpperBound && version.StartsWith('[')) + ); +} diff --git a/CentralConfigGenerator.Core/Workspace/BatchService.cs b/CentralConfigGenerator.Core/Workspace/BatchService.cs new file mode 100644 index 0000000..72120d1 --- /dev/null +++ b/CentralConfigGenerator.Core/Workspace/BatchService.cs @@ -0,0 +1,190 @@ +using System.Collections.Concurrent; +using CentralConfigGenerator.Core.Discovery; +using CentralConfigGenerator.Core.IO; +using CentralConfigGenerator.Core.Migration; +using CentralConfigGenerator.Core.Models; + +namespace CentralConfigGenerator.Core.Workspace; + +public sealed record BatchOptions +{ + public required string RootDirectory { get; init; } + + /// Process repositories concurrently. + public bool Parallel { get; init; } + + /// Keep going after a repository fails instead of stopping at the first error. + public bool ContinueOnError { get; init; } + + public int MaxParallelism { get; init; } = 4; + + public string? ExcludePattern { get; init; } + + /// Maximum directory depth to search for solutions. + public int MaxDepth { get; init; } = 4; +} + +public sealed record BatchItemResult +{ + public required string Workspace { get; init; } + + public required bool Succeeded { get; init; } + + public MigrationPlan? Plan { get; init; } + + public string? Error { get; init; } +} + +public sealed record BatchResult +{ + public required IReadOnlyList Items { get; init; } + + public int SucceededCount => Items.Count(i => i.Succeeded); + + public int FailedCount => Items.Count(i => !i.Succeeded); +} + +/// +/// Runs an operation across every solution in a monorepo. Each workspace is independent, so +/// failures are isolated and (optionally) skipped rather than aborting the batch. +/// +public sealed class BatchService(IFileSystem fileSystem) +{ + /// Finds each distinct solution directory beneath the root. + public IReadOnlyList DiscoverWorkspaces(BatchOptions options) + { + var root = Path.GetFullPath(options.RootDirectory); + + if (!fileSystem.DirectoryExists(root)) + { + return []; + } + + var excludeRegex = string.IsNullOrWhiteSpace(options.ExcludePattern) + ? null + : new System.Text.RegularExpressions.Regex( + options.ExcludePattern, + System.Text.RegularExpressions.RegexOptions.IgnoreCase + ); + + var solutions = fileSystem + .EnumerateFiles(root, "*", recursive: true) + .Where(SolutionReader.IsSolution) + .Where(path => Depth(root, path) <= options.MaxDepth) + .Where(path => !IsInExcludedDirectory(root, path, excludeRegex)) + .Select(path => Path.GetDirectoryName(path)!) + .Where(d => !string.IsNullOrEmpty(d)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(d => d, StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (solutions.Count > 0) + { + return solutions; + } + + // No solutions: fall back to directories that directly contain project files. + return fileSystem + .EnumerateFiles(root, "*", recursive: true) + .Where(p => ProjectKindExtensions.FromPath(p).IsProject()) + .Where(path => !IsInExcludedDirectory(root, path, excludeRegex)) + .Select(path => Path.GetDirectoryName(path)!) + .Where(d => !string.IsNullOrEmpty(d)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(d => d, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + public async Task RunAsync( + BatchOptions options, + Func> operation, + IProgress? progress = null, + CancellationToken cancellationToken = default + ) + { + var workspaces = DiscoverWorkspaces(options); + var results = new ConcurrentBag(); + + if (options.Parallel) + { + await Parallel.ForEachAsync( + workspaces, + new ParallelOptions + { + MaxDegreeOfParallelism = Math.Max(1, options.MaxParallelism), + CancellationToken = cancellationToken, + }, + async (workspace, token) => + { + results.Add(await RunOneAsync(workspace, operation, progress, token)); + } + ); + } + else + { + foreach (var workspace in workspaces) + { + var result = await RunOneAsync(workspace, operation, progress, cancellationToken); + results.Add(result); + + if (!result.Succeeded && !options.ContinueOnError) + { + break; + } + } + } + + return new BatchResult + { + Items = results.OrderBy(r => r.Workspace, StringComparer.OrdinalIgnoreCase).ToList(), + }; + } + + private static async Task RunOneAsync( + string workspace, + Func> operation, + IProgress? progress, + CancellationToken cancellationToken + ) + { + progress?.Report(workspace); + + try + { + var plan = await operation(workspace, cancellationToken); + return new BatchItemResult + { + Workspace = workspace, + Succeeded = true, + Plan = plan, + }; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return new BatchItemResult + { + Workspace = workspace, + Succeeded = false, + Error = ex.Message, + }; + } + } + + private static int Depth(string root, string path) + { + var relative = Path.GetRelativePath(root, path); + return relative.Count(c => c == Path.DirectorySeparatorChar || c == Path.AltDirectorySeparatorChar); + } + + private static bool IsInExcludedDirectory( + string root, + string path, + System.Text.RegularExpressions.Regex? excludeRegex + ) => + ProjectDiscoveryService.IsExcluded( + root, + path, + new DiscoveryOptions { RootDirectory = root }, + excludeRegex + ); +} diff --git a/CentralConfigGenerator.Core/Workspace/DependencyTreeService.cs b/CentralConfigGenerator.Core/Workspace/DependencyTreeService.cs new file mode 100644 index 0000000..c33953d --- /dev/null +++ b/CentralConfigGenerator.Core/Workspace/DependencyTreeService.cs @@ -0,0 +1,118 @@ +using System.Text; +using CentralConfigGenerator.Core.NuGet; + +namespace CentralConfigGenerator.Core.Workspace; + +/// Renders the resolved dependency graph as an ASCII tree. +public sealed class DependencyTreeService(IPackageGraphService graphService) +{ + public async Task RenderAsync( + string target, + string workingDirectory, + bool includeTransitive, + CancellationToken cancellationToken = default + ) + { + var graph = await graphService.LoadAsync( + target, + workingDirectory, + includeTransitive, + cancellationToken + ); + + if (!graph.IsAvailable) + { + return $"Dependency graph unavailable: {graph.Error}"; + } + + return Render(graph, includeTransitive); + } + + internal static string Render(PackageGraph graph, bool includeTransitive) + { + var builder = new StringBuilder(); + + var byProject = graph + .Packages.GroupBy(p => p.ProjectPath, StringComparer.OrdinalIgnoreCase) + .OrderBy(g => g.Key, StringComparer.OrdinalIgnoreCase) + .ToList(); + + for (var projectIndex = 0; projectIndex < byProject.Count; projectIndex++) + { + var project = byProject[projectIndex]; + builder.Append(Path.GetFileNameWithoutExtension(project.Key)).Append('\n'); + + var frameworks = project + .GroupBy(p => p.TargetFramework, StringComparer.OrdinalIgnoreCase) + .OrderBy(g => g.Key, StringComparer.OrdinalIgnoreCase) + .ToList(); + + for (var frameworkIndex = 0; frameworkIndex < frameworks.Count; frameworkIndex++) + { + var framework = frameworks[frameworkIndex]; + var isLastFramework = frameworkIndex == frameworks.Count - 1; + var frameworkPrefix = isLastFramework ? "└── " : "├── "; + var childIndent = isLastFramework ? " " : "│ "; + + builder.Append(frameworkPrefix).Append(framework.Key).Append('\n'); + + var direct = framework + .Where(p => !p.IsTransitive) + .OrderBy(p => p.PackageId, StringComparer.OrdinalIgnoreCase) + .ToList(); + + var transitive = includeTransitive + ? framework + .Where(p => p.IsTransitive) + .OrderBy(p => p.PackageId, StringComparer.OrdinalIgnoreCase) + .ToList() + : []; + + var total = direct.Count + transitive.Count; + var written = 0; + + foreach (var package in direct) + { + written++; + var last = written == total; + builder + .Append(childIndent) + .Append(last ? "└── " : "├── ") + .Append(package.PackageId) + .Append(' ') + .Append(package.ResolvedVersion); + + if ( + package.RequestedVersion is { } requested + && !string.Equals(requested, package.ResolvedVersion, StringComparison.OrdinalIgnoreCase) + ) + { + builder.Append(" (requested ").Append(requested).Append(')'); + } + + builder.Append('\n'); + } + + foreach (var package in transitive) + { + written++; + var last = written == total; + builder + .Append(childIndent) + .Append(last ? "└── " : "├── ") + .Append(package.PackageId) + .Append(' ') + .Append(package.ResolvedVersion) + .Append(" (transitive)\n"); + } + } + + if (projectIndex < byProject.Count - 1) + { + builder.Append('\n'); + } + } + + return builder.ToString(); + } +} diff --git a/CentralConfigGenerator.Core/Workspace/DoctorService.cs b/CentralConfigGenerator.Core/Workspace/DoctorService.cs new file mode 100644 index 0000000..05a2edb --- /dev/null +++ b/CentralConfigGenerator.Core/Workspace/DoctorService.cs @@ -0,0 +1,411 @@ +using CentralConfigGenerator.Core.Configuration; +using CentralConfigGenerator.Core.Discovery; +using CentralConfigGenerator.Core.IO; +using CentralConfigGenerator.Core.NuGet; +using CentralConfigGenerator.Core.Services; +using CentralConfigGenerator.Core.Xml; + +namespace CentralConfigGenerator.Core.Workspace; + +public enum CheckStatus +{ + Pass, + Warn, + Fail, + Skipped, +} + +public sealed record DiagnosticCheck +{ + public required string Name { get; init; } + + public required CheckStatus Status { get; init; } + + public required string Detail { get; init; } + + public string? Remedy { get; init; } +} + +public sealed record DoctorReport +{ + public required IReadOnlyList Checks { get; init; } + + public bool HasFailures => Checks.Any(c => c.Status == CheckStatus.Fail); + + public bool HasWarnings => Checks.Any(c => c.Status == CheckStatus.Warn); +} + +/// +/// Environment diagnostics. Answers "why did that not work?" before the user has to ask, by +/// checking the SDK, the workspace layout and feed reachability in one pass. +/// +public sealed class DoctorService( + IFileSystem fileSystem, + IDotNetCliService cli, + IProjectDiscoveryService discovery, + INuGetFeedService feedService, + ConfigurationLoader configurationLoader +) +{ + public async Task RunAsync( + string rootDirectory, + CancellationToken cancellationToken = default + ) + { + var checks = new List(); + var root = Path.GetFullPath(rootDirectory); + + checks.Add(await CheckSdkAsync(root, cancellationToken)); + checks.Add(CheckGlobalJson(root)); + checks.Add(CheckSolutions(root)); + + var projects = await discovery.DiscoverAsync( + new DiscoveryOptions { RootDirectory = root }, + cancellationToken + ); + + checks.Add( + new DiagnosticCheck + { + Name = "Projects", + Status = projects.Count > 0 ? CheckStatus.Pass : CheckStatus.Fail, + Detail = projects.Count > 0 + ? $"{projects.Count} project file(s) discovered." + : "No .csproj/.fsproj/.vbproj found under this directory.", + Remedy = projects.Count > 0 ? null : "Run from the repository root, or pass --directory.", + } + ); + + checks.Add(await CheckCentralPackageManagementAsync(root, cancellationToken)); + checks.Add(CheckBuildProps(root)); + checks.Add(await CheckFeedsAsync(cancellationToken)); + checks.Add(await CheckConfigurationAsync(root, cancellationToken)); + checks.Add(CheckBackups(root)); + checks.Add(CheckGitIgnore(root)); + + return new DoctorReport { Checks = checks }; + } + + private async Task CheckSdkAsync( + string root, + CancellationToken cancellationToken + ) + { + if (!cli.IsAvailable) + { + return new DiagnosticCheck + { + Name = ".NET SDK", + Status = CheckStatus.Fail, + Detail = "The dotnet CLI was not found on PATH.", + Remedy = "Install the .NET SDK 8.0 or later from https://dot.net.", + }; + } + + var result = await cli.RunAsync( + "--version", + root, + TimeSpan.FromSeconds(30), + cancellationToken + ); + + var version = result.StandardOutput.Trim(); + + if (!result.Succeeded) + { + return new DiagnosticCheck + { + Name = ".NET SDK", + Status = CheckStatus.Fail, + Detail = result.CombinedOutput.Trim(), + Remedy = "Check global.json — it may pin an SDK version that is not installed.", + }; + } + + var major = ParseMajor(version); + + return new DiagnosticCheck + { + Name = ".NET SDK", + Status = major >= 8 ? CheckStatus.Pass : CheckStatus.Warn, + Detail = $"dotnet {version}", + Remedy = major >= 8 + ? null + : "Central package management needs SDK 6.0+; transitive pinning needs 8.0+.", + }; + } + + private DiagnosticCheck CheckGlobalJson(string root) + { + var path = Path.Combine(root, "global.json"); + + return fileSystem.FileExists(path) + ? new DiagnosticCheck + { + Name = "global.json", + Status = CheckStatus.Pass, + Detail = "Present — the SDK version is pinned for this repository.", + } + : new DiagnosticCheck + { + Name = "global.json", + Status = CheckStatus.Warn, + Detail = "Not present.", + Remedy = "Add a global.json so every machine builds with the same SDK.", + }; + } + + private DiagnosticCheck CheckSolutions(string root) + { + var solutions = fileSystem + .EnumerateFiles(root, "*", recursive: false) + .Where(SolutionReader.IsSolution) + .ToList(); + + return solutions.Count switch + { + 0 => new DiagnosticCheck + { + Name = "Solution", + Status = CheckStatus.Warn, + Detail = "No solution file in this directory; the scan will walk the whole tree.", + Remedy = "Pass --solution to scope the run precisely.", + }, + 1 => new DiagnosticCheck + { + Name = "Solution", + Status = CheckStatus.Pass, + Detail = Path.GetFileName(solutions[0]), + }, + _ => new DiagnosticCheck + { + Name = "Solution", + Status = CheckStatus.Warn, + Detail = $"{solutions.Count} solutions found: {string.Join(", ", solutions.Select(Path.GetFileName))}.", + Remedy = "Pass --solution so the tool knows which one you mean.", + }, + }; + } + + private async Task CheckCentralPackageManagementAsync( + string root, + CancellationToken cancellationToken + ) + { + var path = Path.Combine(root, "Directory.Packages.props"); + + if (!fileSystem.FileExists(path)) + { + return new DiagnosticCheck + { + Name = "Central package management", + Status = CheckStatus.Warn, + Detail = "Directory.Packages.props not found.", + Remedy = "Run 'central-config migrate' to create it.", + }; + } + + var file = await discovery.LoadAsync(path, cancellationToken); + + if (file is null) + { + return new DiagnosticCheck + { + Name = "Central package management", + Status = CheckStatus.Fail, + Detail = "Directory.Packages.props exists but could not be read.", + Remedy = "Check file permissions.", + }; + } + + if (!MsBuildXml.TryParse(file.Content, out var document, out var error)) + { + return new DiagnosticCheck + { + Name = "Central package management", + Status = CheckStatus.Fail, + Detail = $"Directory.Packages.props could not be parsed: {error}", + Remedy = "Fix the XML, or restore it from .centralconfig-backups.", + }; + } + + var enabled = MsBuildXml + .Descendants(document, "ManagePackageVersionsCentrally") + .Any(e => string.Equals(e.Value.Trim(), "true", StringComparison.OrdinalIgnoreCase)); + + var count = PackageReferenceReader.ReadPackageVersions(document).Count; + + return new DiagnosticCheck + { + Name = "Central package management", + Status = enabled ? CheckStatus.Pass : CheckStatus.Warn, + Detail = enabled + ? $"Enabled with {count} pinned package(s)." + : $"{count} entries present but ManagePackageVersionsCentrally is not true.", + Remedy = enabled + ? null + : "Set true.", + }; + } + + private DiagnosticCheck CheckBuildProps(string root) + { + var path = Path.Combine(root, "Directory.Build.props"); + + return fileSystem.FileExists(path) + ? new DiagnosticCheck + { + Name = "Directory.Build.props", + Status = CheckStatus.Pass, + Detail = "Present — shared properties are centralised.", + } + : new DiagnosticCheck + { + Name = "Directory.Build.props", + Status = CheckStatus.Warn, + Detail = "Not present.", + Remedy = "Run 'central-config build' to hoist shared properties.", + }; + } + + private async Task CheckFeedsAsync(CancellationToken cancellationToken) + { + var sources = feedService.Sources; + + if (sources.Count == 0) + { + return new DiagnosticCheck + { + Name = "NuGet feeds", + Status = CheckStatus.Fail, + Detail = "No enabled package sources were found.", + Remedy = "Check your nuget.config.", + }; + } + + // A cheap round-trip against a package that exists on every public mirror. + var probe = await feedService.GetMetadataAsync("Newtonsoft.Json", cancellationToken); + + return probe.Found + ? new DiagnosticCheck + { + Name = "NuGet feeds", + Status = CheckStatus.Pass, + Detail = $"{sources.Count} source(s) configured and reachable.", + } + : new DiagnosticCheck + { + Name = "NuGet feeds", + Status = CheckStatus.Warn, + Detail = probe.Error ?? "The probe package could not be resolved.", + Remedy = "Online checks (--audit, --outdated) will be skipped or incomplete.", + }; + } + + private async Task CheckConfigurationAsync( + string root, + CancellationToken cancellationToken + ) + { + var (settings, path, error) = await configurationLoader.LoadAsync(root, cancellationToken); + + if (error is not null) + { + return new DiagnosticCheck + { + Name = "Configuration", + Status = CheckStatus.Fail, + Detail = $"{path} is invalid: {error}", + Remedy = "Fix the JSON or delete the file.", + }; + } + + return settings is null + ? new DiagnosticCheck + { + Name = "Configuration", + Status = CheckStatus.Skipped, + Detail = $"No {ConfigurationLoader.FileName} found; command-line defaults apply.", + Remedy = "Run 'central-config init' to scaffold one.", + } + : new DiagnosticCheck + { + Name = "Configuration", + Status = CheckStatus.Pass, + Detail = $"Loaded from {path}.", + }; + } + + private DiagnosticCheck CheckBackups(string root) + { + var path = Path.Combine(root, BackupService.DefaultBackupFolderName); + + if (!fileSystem.DirectoryExists(path)) + { + return new DiagnosticCheck + { + Name = "Backups", + Status = CheckStatus.Skipped, + Detail = "No backups yet.", + }; + } + + var count = fileSystem.EnumerateDirectories(path).Count; + + return new DiagnosticCheck + { + Name = "Backups", + Status = count > 10 ? CheckStatus.Warn : CheckStatus.Pass, + Detail = $"{count} backup set(s) stored.", + Remedy = count > 10 ? "Run 'central-config backups prune' to trim old sets." : null, + }; + } + + private DiagnosticCheck CheckGitIgnore(string root) + { + var gitDirectory = Path.Combine(root, ".git"); + if (!fileSystem.DirectoryExists(gitDirectory)) + { + return new DiagnosticCheck + { + Name = ".gitignore", + Status = CheckStatus.Skipped, + Detail = "Not a git repository.", + }; + } + + var path = Path.Combine(root, ".gitignore"); + if (!fileSystem.FileExists(path)) + { + return new DiagnosticCheck + { + Name = ".gitignore", + Status = CheckStatus.Warn, + Detail = "No .gitignore in the repository root.", + Remedy = "Pass --add-gitignore so backups are not committed.", + }; + } + + var content = fileSystem.ReadAllTextAsync(path).GetAwaiter().GetResult(); + var ignored = content.Contains( + BackupService.DefaultBackupFolderName, + StringComparison.OrdinalIgnoreCase + ); + + return new DiagnosticCheck + { + Name = ".gitignore", + Status = ignored ? CheckStatus.Pass : CheckStatus.Warn, + Detail = ignored + ? "Backup directory is ignored." + : "Backup directory is not ignored.", + Remedy = ignored ? null : "Pass --add-gitignore on the next run.", + }; + } + + private static int ParseMajor(string version) + { + var head = version.Split('.').FirstOrDefault(); + return int.TryParse(head, out var major) ? major : 0; + } +} diff --git a/CentralConfigGenerator.Core/Workspace/PackageUpdateService.cs b/CentralConfigGenerator.Core/Workspace/PackageUpdateService.cs new file mode 100644 index 0000000..7581174 --- /dev/null +++ b/CentralConfigGenerator.Core/Workspace/PackageUpdateService.cs @@ -0,0 +1,508 @@ +using CentralConfigGenerator.Core.Discovery; +using CentralConfigGenerator.Core.Generators; +using CentralConfigGenerator.Core.IO; +using CentralConfigGenerator.Core.Models; +using CentralConfigGenerator.Core.NuGet; +using CentralConfigGenerator.Core.Services; +using CentralConfigGenerator.Core.Services.Abstractions; +using NuGet.Versioning; + +namespace CentralConfigGenerator.Core.Workspace; + +public sealed record PackageUpdateOptions +{ + public string RootDirectory { get; init; } = Directory.GetCurrentDirectory(); + + public string? SolutionPath { get; init; } + + public string? PackagesPropsPath { get; init; } + + public bool DryRun { get; init; } + + public bool IncludePrerelease { get; init; } + + /// Keep the largest subset of updates that still passes tests instead of reverting all. + public bool Bisect { get; init; } + + /// Upper bound on restore + test cycles spent bisecting. + public int BisectBudget { get; init; } = 16; + + public string? TestFilter { get; init; } + + /// Restrict the run to these package ids. + public IReadOnlyCollection Only { get; init; } = []; + + public bool CreateBackup { get; init; } = true; + + public string? BackupDirectory { get; init; } + + /// Skip the test run entirely and just write the updates. + public bool SkipTests { get; init; } + + public int MaxParallelism { get; init; } = 8; +} + +public sealed record PackageUpdateCandidate +{ + public required string PackageId { get; init; } + + public required string CurrentVersion { get; init; } + + public required string NewVersion { get; init; } + + public bool IsMajorUpgrade { get; init; } +} + +public sealed record PackageUpdateResult +{ + public required IReadOnlyList Candidates { get; init; } + + public required IReadOnlyList Applied { get; init; } + + public required IReadOnlyList HeldBack { get; init; } + + public bool TestsRun { get; init; } + + public bool TestsPassed { get; init; } + + public int BisectCycles { get; init; } + + public string? Message { get; init; } + + public BackupSet? Backup { get; init; } + + public bool RolledBack { get; init; } +} + +public interface IPackageUpdateService +{ + Task UpdateAsync( + PackageUpdateOptions options, + IProgress? progress = null, + CancellationToken cancellationToken = default + ); +} + +/// +/// Updates central package versions, runs the test suite, and rolls back on failure. With +/// --bisect it keeps the largest subset of updates that still passes instead of reverting +/// everything, and names the packages it held back. +/// +public sealed class PackageUpdateService( + IProjectDiscoveryService discovery, + IFileSystem fileSystem, + INuGetFeedService feedService, + IDotNetCliService cli, + IBackupService backupService +) : IPackageUpdateService +{ + public async Task UpdateAsync( + PackageUpdateOptions options, + IProgress? progress = null, + CancellationToken cancellationToken = default + ) + { + var propsPath = options.PackagesPropsPath + ?? Path.Combine(options.RootDirectory, "Directory.Packages.props"); + + if (!fileSystem.FileExists(propsPath)) + { + return Empty("No Directory.Packages.props found. Run the migration first."); + } + + var propsFile = await discovery.LoadAsync(propsPath, cancellationToken); + if (propsFile is null) + { + return Empty($"Could not read {propsPath}."); + } + + var original = propsFile.Content; + var currentVersions = ReadVersions(original); + + if (currentVersions.Count == 0) + { + return Empty("Directory.Packages.props declares no PackageVersion entries."); + } + + progress?.Report("Checking the feed for newer versions"); + + var only = options.Only.ToHashSet(StringComparer.OrdinalIgnoreCase); + var ids = currentVersions + .Keys.Where(id => only.Count == 0 || only.Contains(id)) + .ToList(); + + var metadata = await feedService.GetMetadataAsync( + ids, + options.MaxParallelism, + null, + cancellationToken + ); + + var candidates = BuildCandidates(currentVersions, metadata, options.IncludePrerelease); + + if (candidates.Count == 0) + { + return new PackageUpdateResult + { + Candidates = [], + Applied = [], + HeldBack = [], + Message = "Everything is already up to date.", + }; + } + + if (options.DryRun) + { + return new PackageUpdateResult + { + Candidates = candidates, + Applied = [], + HeldBack = [], + Message = $"{candidates.Count} update(s) available (dry run).", + }; + } + + var backup = options.CreateBackup + ? await backupService.CreateAsync( + options.RootDirectory, + options.BackupDirectory ?? options.RootDirectory, + "update", + [propsPath], + cancellationToken + ) + : null; + + var target = options.SolutionPath ?? options.RootDirectory; + + if (options.SkipTests || !cli.IsAvailable) + { + await WriteAsync(propsFile, original, candidates, cancellationToken); + + return new PackageUpdateResult + { + Candidates = candidates, + Applied = candidates, + HeldBack = [], + Backup = backup, + Message = options.SkipTests + ? "Applied without running tests." + : "dotnet CLI unavailable; applied without running tests.", + }; + } + + var cycles = 0; + + progress?.Report($"Applying {candidates.Count} update(s) and running tests"); + + if (await TryAsync(propsFile, original, candidates, options, target, cancellationToken)) + { + cycles++; + return new PackageUpdateResult + { + Candidates = candidates, + Applied = candidates, + HeldBack = [], + TestsRun = true, + TestsPassed = true, + BisectCycles = cycles, + Backup = backup, + Message = $"All {candidates.Count} update(s) applied; tests passed.", + }; + } + + cycles++; + + if (!options.Bisect) + { + await RestoreAsync(propsFile, original, cancellationToken); + + return new PackageUpdateResult + { + Candidates = candidates, + Applied = [], + HeldBack = candidates, + TestsRun = true, + TestsPassed = false, + BisectCycles = cycles, + Backup = backup, + RolledBack = true, + Message = "Tests failed; all updates were rolled back. Re-run with --bisect to keep the green subset.", + }; + } + + progress?.Report("Tests failed; bisecting for the largest green subset"); + + var budget = Math.Max(2, options.BisectBudget); + var green = await BisectAsync( + propsFile, + original, + candidates, + options, + target, + budget, + cycles, + progress, + cancellationToken + ); + + cycles = green.Cycles; + + if (green.Subset.Count == 0) + { + await RestoreAsync(propsFile, original, cancellationToken); + + return new PackageUpdateResult + { + Candidates = candidates, + Applied = [], + HeldBack = candidates, + TestsRun = true, + TestsPassed = false, + BisectCycles = cycles, + Backup = backup, + RolledBack = true, + Message = "No subset of the updates passed; everything was rolled back.", + }; + } + + await WriteAsync(propsFile, original, green.Subset, cancellationToken); + + var heldBack = candidates.Except(green.Subset).ToList(); + + return new PackageUpdateResult + { + Candidates = candidates, + Applied = green.Subset, + HeldBack = heldBack, + TestsRun = true, + TestsPassed = true, + BisectCycles = cycles, + Backup = backup, + Message = + $"Kept {green.Subset.Count} of {candidates.Count} update(s). Held back: " + + string.Join(", ", heldBack.Select(c => c.PackageId)), + }; + } + + /// + /// Divide and conquer: if a set fails, split it, keep whichever halves pass, then confirm the + /// union. Each restore+test cycle is charged against the budget. + /// + private async Task<(List Subset, int Cycles)> BisectAsync( + ProjectFile propsFile, + string original, + IReadOnlyList candidates, + PackageUpdateOptions options, + string target, + int budget, + int cycles, + IProgress? progress, + CancellationToken cancellationToken + ) + { + if (candidates.Count == 0 || cycles >= budget) + { + return ([], cycles); + } + + if (candidates.Count == 1) + { + progress?.Report($"Testing {candidates[0].PackageId} on its own"); + cycles++; + + var ok = await TryAsync( + propsFile, + original, + candidates, + options, + target, + cancellationToken + ); + + return (ok ? candidates.ToList() : [], cycles); + } + + var middle = candidates.Count / 2; + var left = candidates.Take(middle).ToList(); + var right = candidates.Skip(middle).ToList(); + + var leftResult = await TestSubsetAsync(left); + var rightResult = await TestSubsetAsync(right); + + List best; + + if (leftResult.Green && rightResult.Green) + { + // Both halves pass alone; confirm they also pass together. + if (cycles < budget) + { + cycles++; + var combined = left.Concat(right).ToList(); + if (await TryAsync(propsFile, original, combined, options, target, cancellationToken)) + { + return (combined, cycles); + } + } + + best = left.Count >= right.Count ? leftResult.Subset : rightResult.Subset; + } + else + { + best = leftResult.Subset.Count >= rightResult.Subset.Count + ? leftResult.Subset + : rightResult.Subset; + } + + return (best, cycles); + + async Task<(bool Green, List Subset)> TestSubsetAsync( + List subset + ) + { + if (subset.Count == 0 || cycles >= budget) + { + return (false, []); + } + + progress?.Report($"Testing a subset of {subset.Count} update(s)"); + cycles++; + + if (await TryAsync(propsFile, original, subset, options, target, cancellationToken)) + { + return (true, subset); + } + + var (nested, nestedCycles) = await BisectAsync( + propsFile, + original, + subset, + options, + target, + budget, + cycles, + progress, + cancellationToken + ); + + cycles = nestedCycles; + return (false, nested); + } + } + + private async Task TryAsync( + ProjectFile propsFile, + string original, + IReadOnlyList subset, + PackageUpdateOptions options, + string target, + CancellationToken cancellationToken + ) + { + await WriteAsync(propsFile, original, subset, cancellationToken); + + var restore = await cli.RestoreAsync(target, options.RootDirectory, cancellationToken); + if (!restore.Succeeded) + { + return false; + } + + var test = await cli.TestAsync( + target, + options.RootDirectory, + options.TestFilter, + cancellationToken + ); + + return test.Succeeded; + } + + private async Task WriteAsync( + ProjectFile propsFile, + string original, + IReadOnlyList subset, + CancellationToken cancellationToken + ) + { + var content = original; + + foreach (var candidate in subset) + { + content = + PackagesPropsEditor.SetVersion(content, candidate.PackageId, candidate.NewVersion) + ?? content; + } + + await fileSystem.WriteAllBytesAsync( + propsFile.Path, + EncodingDetector.Encode(content, propsFile.Format), + cancellationToken + ); + } + + private Task RestoreAsync( + ProjectFile propsFile, + string original, + CancellationToken cancellationToken + ) => + fileSystem.WriteAllBytesAsync( + propsFile.Path, + EncodingDetector.Encode(original, propsFile.Format), + cancellationToken + ); + + private static List BuildCandidates( + IReadOnlyDictionary current, + IReadOnlyDictionary metadata, + bool includePrerelease + ) + { + var candidates = new List(); + + foreach (var (packageId, version) in current.OrderBy(p => p.Key, StringComparer.OrdinalIgnoreCase)) + { + if ( + !metadata.TryGetValue(packageId, out var snapshot) + || !snapshot.Found + || !NuGetVersion.TryParse(version, out var currentVersion) + ) + { + continue; + } + + var latest = includePrerelease + ? snapshot.LatestIncludingPrerelease + : snapshot.LatestStable; + + if (latest is null || latest <= currentVersion) + { + continue; + } + + candidates.Add( + new PackageUpdateCandidate + { + PackageId = packageId, + CurrentVersion = version, + NewVersion = latest.ToNormalizedString(), + IsMajorUpgrade = latest.Major > currentVersion.Major, + } + ); + } + + return candidates; + } + + private static IReadOnlyDictionary ReadVersions(string content) => + Xml.MsBuildXml.TryParse(content, out var document, out _) + ? Xml.PackageReferenceReader.ReadPackageVersions(document) + : new Dictionary(StringComparer.OrdinalIgnoreCase); + + private static PackageUpdateResult Empty(string message) => + new() + { + Candidates = [], + Applied = [], + HeldBack = [], + Message = message, + }; +} diff --git a/CentralConfigGenerator.Core/Workspace/StatusService.cs b/CentralConfigGenerator.Core/Workspace/StatusService.cs new file mode 100644 index 0000000..e6ef3f6 --- /dev/null +++ b/CentralConfigGenerator.Core/Workspace/StatusService.cs @@ -0,0 +1,105 @@ +using CentralConfigGenerator.Core.Analysis; +using CentralConfigGenerator.Core.Discovery; +using CentralConfigGenerator.Core.IO; +using CentralConfigGenerator.Core.Models; +using CentralConfigGenerator.Core.Services; + +namespace CentralConfigGenerator.Core.Workspace; + +/// One-shot snapshot of a workspace: what is there and how healthy it is. +public sealed record WorkspaceStatus +{ + public required string RootDirectory { get; init; } + + public required int ProjectCount { get; init; } + + public required int PackageCount { get; init; } + + public required bool CentralPackageManagementEnabled { get; init; } + + public required bool TransitivePinningEnabled { get; init; } + + public bool HasBuildProps { get; init; } + + public bool HasConfigFile { get; init; } + + public int BackupCount { get; init; } + + public IReadOnlyList Solutions { get; init; } = []; + + public IReadOnlyDictionary ProjectKinds { get; init; } = + new Dictionary(); + + public IReadOnlyList TargetFrameworks { get; init; } = []; + + public required AnalysisReport Analysis { get; init; } + + /// The single next action that would most improve this workspace. + public string NextStep => + !CentralPackageManagementEnabled + ? "Run 'central-config migrate --dry-run --diff' to preview central package management." + : Analysis.CountOf(Severity.Critical) > 0 + ? "Run 'central-config analyze --audit' — there are critical findings." + : Analysis.Findings.Count(f => f.IsAutoFixable) > 0 + ? "Run 'central-config analyze --fix' to apply the auto-fixable findings." + : !HasBuildProps + ? "Run 'central-config build' to centralise shared MSBuild properties." + : "Nothing urgent. Consider 'central-config update --bisect' to move versions forward."; +} + +/// Builds the status dashboard from a fast, offline-only analysis pass. +public sealed class StatusService( + IAnalysisEngine analysisEngine, + IFileSystem fileSystem, + Configuration.ConfigurationLoader configurationLoader +) +{ + public async Task GetAsync( + string rootDirectory, + AnalysisOptions? analysisOptions = null, + CancellationToken cancellationToken = default + ) + { + var root = Path.GetFullPath(rootDirectory); + var options = analysisOptions ?? new AnalysisOptions { RootDirectory = root }; + + var context = await analysisEngine.BuildContextAsync(options, null, cancellationToken); + var report = await analysisEngine.AnalyzeAsync(options, null, cancellationToken); + + var backupRoot = Path.Combine(root, BackupService.DefaultBackupFolderName); + + return new WorkspaceStatus + { + RootDirectory = root, + ProjectCount = context.Projects.Count, + PackageCount = context + .References.Select(r => r.PackageId) + .Concat(context.CentralVersions.Keys) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Count(), + CentralPackageManagementEnabled = context.CentralPackageManagementEnabled, + TransitivePinningEnabled = context.TransitivePinningEnabled, + HasBuildProps = fileSystem.FileExists(Path.Combine(root, "Directory.Build.props")), + HasConfigFile = configurationLoader.Find(root) is not null, + BackupCount = fileSystem.DirectoryExists(backupRoot) + ? fileSystem.EnumerateDirectories(backupRoot).Count + : 0, + Solutions = fileSystem + .EnumerateFiles(root, "*", recursive: false) + .Where(SolutionReader.IsSolution) + .Select(Path.GetFileName) + .Where(n => n is not null) + .Select(n => n!) + .ToList(), + ProjectKinds = context + .Projects.GroupBy(p => p.ResolvedKind) + .ToDictionary(g => g.Key, g => g.Count()), + TargetFrameworks = context + .TargetFrameworks.SelectMany(pair => pair.Value) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(t => t, StringComparer.OrdinalIgnoreCase) + .ToList(), + Analysis = report, + }; + } +} diff --git a/CentralConfigGenerator.Core/Workspace/VerificationService.cs b/CentralConfigGenerator.Core/Workspace/VerificationService.cs new file mode 100644 index 0000000..04193fd --- /dev/null +++ b/CentralConfigGenerator.Core/Workspace/VerificationService.cs @@ -0,0 +1,167 @@ +using CentralConfigGenerator.Core.NuGet; +using CentralConfigGenerator.Core.Services; + +namespace CentralConfigGenerator.Core.Workspace; + +/// One package whose resolved version changed between two graph snapshots. +public sealed record GraphDrift +{ + public required string Key { get; init; } + + public required string PackageId { get; init; } + + public string? Before { get; init; } + + public string? After { get; init; } + + public bool IsAddition => Before is null; + + public bool IsRemoval => After is null; +} + +public sealed record VerificationResult +{ + public required bool GraphAvailable { get; init; } + + public required IReadOnlyList Drift { get; init; } + + public string? Error { get; init; } + + public int BeforeCount { get; init; } + + public int AfterCount { get; init; } + + public bool IsClean => Drift.Count == 0; +} + +public interface IVerificationService +{ + Task SnapshotAsync( + string target, + string workingDirectory, + CancellationToken cancellationToken = default + ); + + VerificationResult Compare(PackageGraph before, PackageGraph after); +} + +/// +/// Proves a migration did not change what NuGet actually resolves, by diffing the full package +/// graph before and after. This is the difference between "the files look right" and "the build +/// produces the same binaries". +/// +public sealed class VerificationService( + IPackageGraphService graphService, + IDotNetCliService cli +) : IVerificationService +{ + public async Task SnapshotAsync( + string target, + string workingDirectory, + CancellationToken cancellationToken = default + ) + { + // dotnet list package needs an up-to-date assets file to report anything useful. + var restore = await cli.RestoreAsync(target, workingDirectory, cancellationToken); + if (!restore.Succeeded) + { + return PackageGraph.Empty with + { + Error = $"restore failed: {FirstError(restore.CombinedOutput)}", + }; + } + + return await graphService.LoadAsync( + target, + workingDirectory, + includeTransitive: true, + cancellationToken + ); + } + + public VerificationResult Compare(PackageGraph before, PackageGraph after) + { + if (!before.IsAvailable || !after.IsAvailable) + { + return new VerificationResult + { + GraphAvailable = false, + Drift = [], + Error = before.Error ?? after.Error, + BeforeCount = before.Packages.Count, + AfterCount = after.Packages.Count, + }; + } + + var beforeSignature = before.Signature(); + var afterSignature = after.Signature(); + var drift = new List(); + + foreach (var (key, version) in beforeSignature) + { + if (!afterSignature.TryGetValue(key, out var newVersion)) + { + drift.Add( + new GraphDrift + { + Key = key, + PackageId = PackageIdFromKey(key), + Before = version, + After = null, + } + ); + continue; + } + + if (!string.Equals(version, newVersion, StringComparison.OrdinalIgnoreCase)) + { + drift.Add( + new GraphDrift + { + Key = key, + PackageId = PackageIdFromKey(key), + Before = version, + After = newVersion, + } + ); + } + } + + foreach (var (key, version) in afterSignature) + { + if (!beforeSignature.ContainsKey(key)) + { + drift.Add( + new GraphDrift + { + Key = key, + PackageId = PackageIdFromKey(key), + Before = null, + After = version, + } + ); + } + } + + return new VerificationResult + { + GraphAvailable = true, + Drift = drift.OrderBy(d => d.PackageId, StringComparer.OrdinalIgnoreCase).ToList(), + BeforeCount = beforeSignature.Count, + AfterCount = afterSignature.Count, + }; + } + + private static string PackageIdFromKey(string key) + { + var index = key.LastIndexOf('|'); + return index < 0 ? key : key[(index + 1)..]; + } + + private static string FirstError(string output) => + output + .Split('\n') + .Select(l => l.Trim()) + .FirstOrDefault(l => l.Contains("error", StringComparison.OrdinalIgnoreCase)) + ?? "see build output"; +} diff --git a/CentralConfigGenerator.Core/Xml/MsBuildXml.cs b/CentralConfigGenerator.Core/Xml/MsBuildXml.cs new file mode 100644 index 0000000..7394884 --- /dev/null +++ b/CentralConfigGenerator.Core/Xml/MsBuildXml.cs @@ -0,0 +1,156 @@ +using System.Text; +using System.Xml; +using System.Xml.Linq; + +namespace CentralConfigGenerator.Core.Xml; + +/// +/// Helpers for editing MSBuild XML with as little collateral reformatting as possible. +/// +public static class MsBuildXml +{ + /// + /// Parses MSBuild XML keeping all whitespace so untouched regions round-trip byte-for-byte. + /// + public static XDocument Parse(string content) => + XDocument.Parse(content, LoadOptions.PreserveWhitespace); + + public static bool TryParse(string content, out XDocument document, out string? error) + { + try + { + document = Parse(content); + error = null; + return true; + } + catch (XmlException ex) + { + document = new XDocument(); + error = ex.Message; + return false; + } + } + + /// + /// Serialises a document, keeping the original XML declaration and indentation style. + /// + public static string ToString(XDocument document, bool preserveWhitespace = true) + { + var builder = new StringBuilder(); + var settings = new XmlWriterSettings + { + Indent = !preserveWhitespace, + IndentChars = " ", + OmitXmlDeclaration = document.Declaration is null, + Encoding = new UTF8Encoding(false), + NewLineHandling = NewLineHandling.None, + }; + + using (var writer = XmlWriter.Create(builder, settings)) + { + document.Save(writer); + } + + return builder.ToString(); + } + + /// + /// Removes an element along with the insignificant whitespace that preceded it, so the + /// surrounding file does not gain blank lines. + /// + public static void RemoveWithWhitespace(XElement element) + { + if (element.PreviousNode is XText { } text && text.Value.Trim().Length == 0) + { + text.Remove(); + } + + element.Remove(); + } + + /// + /// Removes an item group once it no longer has any element children. + /// + public static void RemoveIfEmpty(XElement? container) + { + if (container is null || container.Elements().Any()) + { + return; + } + + RemoveWithWhitespace(container); + } + + /// + /// Returns the indentation string used by the closest sibling, defaulting to two spaces + /// per nesting level. + /// + public static string GetIndent(XElement element) + { + var depth = 0; + for (var parent = element.Parent; parent is not null; parent = parent.Parent) + { + depth++; + } + + if (element.PreviousNode is XText { } text) + { + var value = text.Value; + var index = value.LastIndexOfAny(['\n', '\r']); + if (index >= 0) + { + return value[(index + 1)..]; + } + } + + return new string(' ', depth * 2); + } + + /// Gets an element's attribute value case-insensitively. + public static string? AttributeValue(XElement element, string name) + { + var attribute = element + .Attributes() + .FirstOrDefault(a => + string.Equals(a.Name.LocalName, name, StringComparison.OrdinalIgnoreCase) + ); + + return attribute?.Value; + } + + public static XAttribute? Attribute(XElement element, string name) => + element + .Attributes() + .FirstOrDefault(a => + string.Equals(a.Name.LocalName, name, StringComparison.OrdinalIgnoreCase) + ); + + /// Finds descendants by local name, ignoring any MSBuild XML namespace. + public static IEnumerable Descendants(XContainer container, string localName) => + container + .Descendants() + .Where(e => string.Equals(e.Name.LocalName, localName, StringComparison.OrdinalIgnoreCase)); + + public static IEnumerable Elements(XContainer container, string localName) => + container + .Elements() + .Where(e => string.Equals(e.Name.LocalName, localName, StringComparison.OrdinalIgnoreCase)); + + /// Returns the effective condition of an element, including inherited item-group conditions. + public static string? EffectiveCondition(XElement element) + { + var conditions = new List(); + + for (var current = element; current is not null; current = current.Parent) + { + var condition = AttributeValue(current, "Condition"); + if (!string.IsNullOrWhiteSpace(condition)) + { + conditions.Add(condition.Trim()); + } + } + + conditions.Reverse(); + return conditions.Count == 0 ? null : string.Join(" AND ", conditions); + } +} diff --git a/CentralConfigGenerator.Core/Xml/PackageReferenceReader.cs b/CentralConfigGenerator.Core/Xml/PackageReferenceReader.cs new file mode 100644 index 0000000..06091f2 --- /dev/null +++ b/CentralConfigGenerator.Core/Xml/PackageReferenceReader.cs @@ -0,0 +1,107 @@ +using System.Xml.Linq; +using CentralConfigGenerator.Core.Models; + +namespace CentralConfigGenerator.Core.Xml; + +/// +/// Extracts package references from project XML, covering attribute versions, child-element +/// versions, VersionOverride and GlobalPackageReference. +/// +public static class PackageReferenceReader +{ + public static IReadOnlyList Read(ProjectFile projectFile) + { + if (!MsBuildXml.TryParse(projectFile.Content, out var document, out _)) + { + return []; + } + + return Read(document, projectFile.Path); + } + + public static IReadOnlyList Read(XDocument document, string projectPath) + { + var results = new List(); + + foreach (var element in MsBuildXml.Descendants(document, "PackageReference")) + { + var info = ReadOne(element, projectPath, isGlobal: false); + if (info is not null) + { + results.Add(info); + } + } + + foreach (var element in MsBuildXml.Descendants(document, "GlobalPackageReference")) + { + var info = ReadOne(element, projectPath, isGlobal: true); + if (info is not null) + { + results.Add(info); + } + } + + return results; + } + + /// Reads PackageVersion entries from a Directory.Packages.props document. + public static IReadOnlyDictionary ReadPackageVersions(XDocument document) + { + var results = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var element in MsBuildXml.Descendants(document, "PackageVersion")) + { + var id = MsBuildXml.AttributeValue(element, "Include") + ?? MsBuildXml.AttributeValue(element, "Update"); + if (string.IsNullOrWhiteSpace(id)) + { + continue; + } + + var version = + MsBuildXml.AttributeValue(element, "Version") + ?? MsBuildXml.Elements(element, "Version").FirstOrDefault()?.Value; + + if (!string.IsNullOrWhiteSpace(version)) + { + results[id.Trim()] = version.Trim(); + } + } + + return results; + } + + private static PackageReferenceInfo? ReadOne(XElement element, string projectPath, bool isGlobal) + { + var id = + MsBuildXml.AttributeValue(element, "Include") + ?? MsBuildXml.AttributeValue(element, "Update"); + + if (string.IsNullOrWhiteSpace(id)) + { + return null; + } + + var versionAttribute = MsBuildXml.Attribute(element, "Version"); + var versionElement = MsBuildXml.Elements(element, "Version").FirstOrDefault(); + var overrideAttribute = MsBuildXml.Attribute(element, "VersionOverride"); + + var version = + versionAttribute?.Value ?? versionElement?.Value ?? overrideAttribute?.Value; + + var privateAssets = + MsBuildXml.AttributeValue(element, "PrivateAssets") + ?? MsBuildXml.Elements(element, "PrivateAssets").FirstOrDefault()?.Value; + + return new PackageReferenceInfo + { + PackageId = id.Trim(), + Version = string.IsNullOrWhiteSpace(version) ? null : version.Trim(), + ProjectPath = projectPath, + Condition = MsBuildXml.EffectiveCondition(element), + VersionIsElement = versionAttribute is null && versionElement is not null, + IsGlobal = isGlobal, + IsPrivateAssets = !string.IsNullOrWhiteSpace(privateAssets), + }; + } +} diff --git a/CentralConfigGenerator.Tests/CentralConfigGenerator.Tests.csproj b/CentralConfigGenerator.Tests/CentralConfigGenerator.Tests.csproj index 349e98f..a3444fe 100644 --- a/CentralConfigGenerator.Tests/CentralConfigGenerator.Tests.csproj +++ b/CentralConfigGenerator.Tests/CentralConfigGenerator.Tests.csproj @@ -1,5 +1,7 @@ + net9.0 + Major false true diff --git a/CentralConfigGenerator.Tests/Cli/CompletionsCommandTests.cs b/CentralConfigGenerator.Tests/Cli/CompletionsCommandTests.cs new file mode 100644 index 0000000..26eb20e --- /dev/null +++ b/CentralConfigGenerator.Tests/Cli/CompletionsCommandTests.cs @@ -0,0 +1,50 @@ +using CentralConfigGenerator.Cli.Commands; + +namespace CentralConfigGenerator.Tests.Cli; + +public class CompletionsCommandTests +{ + [Theory] + [InlineData("bash")] + [InlineData("zsh")] + [InlineData("fish")] + [InlineData("powershell")] + [InlineData("pwsh")] + [InlineData("BASH")] + public void ShouldEmitAScriptForEverySupportedShell(string shell) + { + var script = CompletionsCommand.Render(shell); + + script.ShouldNotBeNull(); + script.ShouldContain("central-config"); + script.ShouldContain("migrate"); + } + + [Theory] + [InlineData("tcsh")] + [InlineData("")] + public void ShouldReturnNothingForAnUnsupportedShell(string shell) => + CompletionsCommand.Render(shell).ShouldBeNull(); + + [Fact] + public void BashScriptShouldDefineACompletionFunction() => + Render("bash").ShouldContain("complete -F _central_config_completions"); + + [Fact] + public void ZshScriptShouldDeclareTheCompdef() => Render("zsh").ShouldContain("#compdef central-config"); + + [Fact] + public void PowerShellScriptShouldRegisterAnArgumentCompleter() => + Render("powershell").ShouldContain("Register-ArgumentCompleter"); + + [Theory] + [InlineData("bash")] + [InlineData("zsh")] + [InlineData("fish")] + [InlineData("powershell")] + public void EveryScriptShouldListTheAnalyzeCommand(string shell) => + Render(shell).ShouldContain("analyze"); + + private static string Render(string shell) => + CompletionsCommand.Render(shell).ShouldNotBeNull(); +} diff --git a/CentralConfigGenerator.Tests/Cli/EffectiveSettingsTests.cs b/CentralConfigGenerator.Tests/Cli/EffectiveSettingsTests.cs new file mode 100644 index 0000000..b2e961a --- /dev/null +++ b/CentralConfigGenerator.Tests/Cli/EffectiveSettingsTests.cs @@ -0,0 +1,140 @@ +using CentralConfigGenerator.Cli.Commands; +using CentralConfigGenerator.Cli.Infrastructure; +using CentralConfigGenerator.Core.Analysis; +using CentralConfigGenerator.Core.Configuration; +using CentralConfigGenerator.Core.IO; +using CentralConfigGenerator.Core.Services; + +namespace CentralConfigGenerator.Tests.Cli; + +public class EffectiveSettingsTests : IDisposable +{ + private readonly string _root = Path.Combine( + Path.GetTempPath(), + "ccg-effective-" + Guid.NewGuid().ToString("N") + ); + + public EffectiveSettingsTests() => Directory.CreateDirectory(_root); + + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + + GC.SuppressFinalize(this); + } + + [Fact] + public async Task LoadAsync_ShouldPickUpTheConfigFile() + { + WriteConfig("""{ "conflictStrategy": "Lowest" }"""); + + var effective = await LoadAsync(); + + effective.File.ShouldNotBeNull(); + effective.Strategy(null).ShouldBe(VersionResolutionStrategy.Lowest); + } + + [Fact] + public async Task LoadAsync_ShouldSkipTheConfigFileWhenDisabled() + { + WriteConfig("""{ "conflictStrategy": "Lowest" }"""); + + var effective = await LoadAsync(noConfig: true); + + effective.File.ShouldBeNull(); + effective.Strategy(null).ShouldBe(VersionResolutionStrategy.Highest); + } + + [Fact] + public async Task Strategy_ShouldPreferTheCommandLine() + { + WriteConfig("""{ "conflictStrategy": "Lowest" }"""); + + var effective = await LoadAsync(); + + effective.Strategy("MostCommon").ShouldBe(VersionResolutionStrategy.MostCommon); + } + + [Fact] + public async Task Strategy_ShouldFallBackToHighestForUnknownValues() + { + var effective = await LoadAsync(); + + effective.Strategy("nonsense").ShouldBe(VersionResolutionStrategy.Highest); + } + + [Fact] + public async Task Bool_ShouldOrTheCommandLineWithTheFile() + { + WriteConfig("""{ "transitivePinning": true }"""); + + var effective = await LoadAsync(); + + effective.Bool(false, c => c.TransitivePinning).ShouldBeTrue(); + effective.Bool(true, c => c.IgnorePrerelease).ShouldBeTrue(); + } + + [Fact] + public async Task Text_ShouldPreferTheCommandLine() + { + WriteConfig("""{ "excludeDirs": "from-file" }"""); + + var effective = await LoadAsync(); + + effective.Text("from-cli", c => c.ExcludeDirs).ShouldBe("from-cli"); + effective.Text(null, c => c.ExcludeDirs).ShouldBe("from-file"); + } + + [Fact] + public async Task RuleOverrides_ShouldMergeFileAndCommandLine() + { + WriteConfig($$"""{ "rules": { "{{RuleIds.OutdatedPackage}}": "Critical" } }"""); + + var effective = await LoadAsync(); + + var overrides = effective.RuleOverrides([$"{RuleIds.FloatingVersion}=High"]); + + overrides[RuleIds.OutdatedPackage].ShouldBe(Severity.Critical); + overrides[RuleIds.FloatingVersion].ShouldBe(Severity.High); + } + + [Fact] + public async Task RuleOverrides_ShouldTreatNoneAsDisabled() + { + var effective = await LoadAsync(); + + var overrides = effective.RuleOverrides([$"{RuleIds.OutdatedPackage}=none"]); + + overrides.ShouldContainKey(RuleIds.OutdatedPackage); + overrides[RuleIds.OutdatedPackage].ShouldBeNull(); + } + + [Fact] + public async Task RuleOverrides_ShouldIgnoreUnknownRules() + { + var effective = await LoadAsync(); + + effective.RuleOverrides(["NotARule=High"]).ShouldBeEmpty(); + } + + [Fact] + public async Task RuleOverrides_ShouldIgnoreMalformedEntries() + { + var effective = await LoadAsync(); + + effective.RuleOverrides(["no-equals-sign"]).ShouldBeEmpty(); + } + + private void WriteConfig(string json) => + File.WriteAllText(Path.Combine(_root, ConfigurationLoader.FileName), json); + + private async Task LoadAsync(bool noConfig = false) + { + var effective = new EffectiveSettings(new ConfigurationLoader(new PhysicalFileSystem())); + await effective.LoadAsync(new MigrateSettings { Directory = _root, NoConfig = noConfig }); + return effective; + } +} diff --git a/CentralConfigGenerator.Tests/Cli/ServiceRegistrationTests.cs b/CentralConfigGenerator.Tests/Cli/ServiceRegistrationTests.cs new file mode 100644 index 0000000..6760508 --- /dev/null +++ b/CentralConfigGenerator.Tests/Cli/ServiceRegistrationTests.cs @@ -0,0 +1,63 @@ +using CentralConfigGenerator.Cli.Commands; +using CentralConfigGenerator.Core.Analysis; +using CentralConfigGenerator.Core.Discovery; +using CentralConfigGenerator.Core.Migration; +using CentralConfigGenerator.Core.Workspace; +using Microsoft.Extensions.DependencyInjection; + +namespace CentralConfigGenerator.Tests.Cli; + +public class ServiceRegistrationTests +{ + [Theory] + [InlineData(typeof(MigrateCommand))] + [InlineData(typeof(BuildPropsCommand))] + [InlineData(typeof(AllCommand))] + [InlineData(typeof(RevertCommand))] + [InlineData(typeof(AnalyzeCommand))] + [InlineData(typeof(UpdateCommand))] + [InlineData(typeof(VerifyCommand))] + [InlineData(typeof(TreeCommand))] + [InlineData(typeof(StatusCommand))] + [InlineData(typeof(DoctorCommand))] + [InlineData(typeof(InitCommand))] + [InlineData(typeof(ExplainCommand))] + [InlineData(typeof(BatchCommand))] + [InlineData(typeof(BackupListCommand))] + [InlineData(typeof(BackupRestoreCommand))] + [InlineData(typeof(BackupPruneCommand))] + [InlineData(typeof(CompletionsCommand))] + public void EveryCommandShouldResolve(Type commandType) + { + using var provider = Program.ConfigureServices(); + + provider.GetService(commandType).ShouldNotBeNull(); + } + + [Theory] + [InlineData(typeof(IMigrationService))] + [InlineData(typeof(IAnalysisEngine))] + [InlineData(typeof(IAutoFixService))] + [InlineData(typeof(IProjectDiscoveryService))] + [InlineData(typeof(IVerificationService))] + [InlineData(typeof(IPackageUpdateService))] + [InlineData(typeof(DoctorService))] + [InlineData(typeof(StatusService))] + [InlineData(typeof(BatchService))] + public void EveryServiceShouldResolve(Type serviceType) + { + using var provider = Program.ConfigureServices(); + + provider.GetService(serviceType).ShouldNotBeNull(); + } + + [Fact] + public void LegacyCommandsShouldStillResolve() + { + using var provider = Program.ConfigureServices(); + + provider.GetService().ShouldNotBeNull(); + provider.GetService().ShouldNotBeNull(); + provider.GetService().ShouldNotBeNull(); + } +} diff --git a/CentralConfigGenerator.Tests/Cli/WorkspaceSettingsTests.cs b/CentralConfigGenerator.Tests/Cli/WorkspaceSettingsTests.cs new file mode 100644 index 0000000..fbd956b --- /dev/null +++ b/CentralConfigGenerator.Tests/Cli/WorkspaceSettingsTests.cs @@ -0,0 +1,58 @@ +using CentralConfigGenerator.Cli.Commands; + +namespace CentralConfigGenerator.Tests.Cli; + +public class WorkspaceSettingsTests +{ + private static readonly string Temp = Path.GetFullPath(Path.GetTempPath()) + .TrimEnd(Path.DirectorySeparatorChar); + + [Fact] + public void ResolvedDirectory_ShouldDefaultToTheCurrentDirectory() => + new MigrateSettings().ResolvedDirectory.ShouldBe( + Directory.GetCurrentDirectory().TrimEnd(Path.DirectorySeparatorChar) + ); + + [Fact] + public void ResolvedDirectory_ShouldFollowTheSolutionFolder() + { + var solution = Path.Combine(Path.GetTempPath(), "Some.sln"); + + new MigrateSettings { Solution = solution }.ResolvedDirectory.ShouldBe(Temp); + } + + [Fact] + public void ResolvedDirectory_ShouldPreferAnExplicitDirectory() + { + var settings = new MigrateSettings + { + Directory = Path.GetTempPath(), + Solution = Path.Combine("elsewhere", "Some.sln"), + }; + + settings.ResolvedDirectory.ShouldBe(Temp); + } + + [Fact] + public void Validate_ShouldRejectAMissingDirectory() + { + var settings = new MigrateSettings { Directory = Path.Combine(Path.GetTempPath(), "nope-" + Guid.NewGuid()) }; + + settings.Validate().Successful.ShouldBeFalse(); + } + + [Fact] + public void Validate_ShouldAcceptAnExistingDirectory() => + new MigrateSettings { Directory = Path.GetTempPath() }.Validate().Successful.ShouldBeTrue(); + + [Fact] + public void Validate_ShouldRejectAMissingSolution() + { + var settings = new MigrateSettings + { + Solution = Path.Combine(Path.GetTempPath(), "missing-" + Guid.NewGuid() + ".sln"), + }; + + settings.Validate().Successful.ShouldBeFalse(); + } +} diff --git a/CentralConfigGenerator/CentralConfigGenerator.csproj b/CentralConfigGenerator/CentralConfigGenerator.csproj index 88b1080..510bd3e 100644 --- a/CentralConfigGenerator/CentralConfigGenerator.csproj +++ b/CentralConfigGenerator/CentralConfigGenerator.csproj @@ -1,9 +1,9 @@ + net8.0;net9.0;net10.0 Exe true true - true CentralConfigGenerator true central-config @@ -13,13 +13,18 @@ README.md - A modern .NET tool for automatically generating centralized configuration files for .NET projects. - CentralConfig analyzes your solution structure and creates properly configured `Directory.Build.props` and `Directory.Packages.props` files to standardize settings across your projects. + Migrate .NET solutions to Central Package Management and keep them healthy. + Generates Directory.Packages.props and Directory.Build.props, verifies the resolved package graph is unchanged, + audits dependencies for vulnerabilities, deprecations, licence risk and version drift, applies auto-fixes, + and updates packages with test-backed rollback. Supports C#, F# and VB, dry-run diffs, backups and rollback, + SARIF/JSON/Markdown/CSV reports, and CI-friendly exit codes. - 1.1.1 + 2.0.0 + 2.0.0.0 + 2.0.0.0 Taras Kovalenko Copyright Taras Kovalenko - dotnet;msbuild;build;props;packages;centralized;configuration;directory-build-props;sdk;cli;tool;nuget;CPM;centralised + dotnet;msbuild;build;props;packages;centralized;centralised;configuration;directory-build-props;directory-packages-props;sdk;cli;tool;nuget;cpm;migration;audit;vulnerabilities;sarif;dependencies;devops CentralConfigGenerator MIT https://github.com/TarasKovalenko/CentralConfigGenerator @@ -28,6 +33,10 @@ false + + + + @@ -36,10 +45,10 @@ - + \ No newline at end of file diff --git a/CentralConfigGenerator/Cli/Commands/AllCommand.cs b/CentralConfigGenerator/Cli/Commands/AllCommand.cs new file mode 100644 index 0000000..854821a --- /dev/null +++ b/CentralConfigGenerator/Cli/Commands/AllCommand.cs @@ -0,0 +1,53 @@ +using CentralConfigGenerator.Cli.Infrastructure; +using CentralConfigGenerator.Core.Models; +using Spectre.Console.Cli; + +namespace CentralConfigGenerator.Cli.Commands; + +public sealed class AllSettings : MigrateSettings; + +/// Generates both Directory.Build.props and Directory.Packages.props in one pass. +public sealed class AllCommand(MigrateCommand migrate) : AsyncCommand +{ + public override async Task ExecuteAsync(CommandContext context, AllSettings settings) + { + // The migration already knows how to hoist properties; --unify-props is what "all" means. + var combined = settings; + + Ui.Quiet = settings.Quiet; + Ui.Verbose = settings.Verbose; + + var migrateSettings = new MigrateSettings + { + Directory = combined.Directory, + Solution = combined.Solution, + Project = combined.Project, + ExcludeDirs = combined.ExcludeDirs, + Quiet = combined.Quiet, + Verbose = combined.Verbose, + NoConfig = combined.NoConfig, + DryRun = combined.DryRun, + Diff = combined.Diff, + NoBackup = combined.NoBackup, + BackupDir = combined.BackupDir, + AddGitIgnore = combined.AddGitIgnore, + Force = combined.Force, + OutputDir = combined.OutputDir, + Merge = combined.Merge, + Overwrite = combined.Overwrite, + KeepAttributes = combined.KeepAttributes, + ConflictStrategy = combined.ConflictStrategy, + InteractiveConflicts = combined.InteractiveConflicts, + MinVersion = combined.MinVersion, + IgnorePrerelease = combined.IgnorePrerelease, + TransitivePinning = combined.TransitivePinning, + Encoding = combined.Encoding, + LineWrap = combined.LineWrap, + VersionComparison = combined.VersionComparison, + UnifyProps = true, + IncludeFSharp = combined.IncludeFSharp, + }; + + return await migrate.ExecuteAsync(context, migrateSettings); + } +} diff --git a/CentralConfigGenerator/Cli/Commands/AnalyzeCommand.cs b/CentralConfigGenerator/Cli/Commands/AnalyzeCommand.cs new file mode 100644 index 0000000..9b241ff --- /dev/null +++ b/CentralConfigGenerator/Cli/Commands/AnalyzeCommand.cs @@ -0,0 +1,290 @@ +using System.ComponentModel; +using CentralConfigGenerator.Cli.Infrastructure; +using CentralConfigGenerator.Cli.Rendering; +using CentralConfigGenerator.Core.Analysis; +using CentralConfigGenerator.Core.Models; +using CentralConfigGenerator.Core.Reporting; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace CentralConfigGenerator.Cli.Commands; + +public sealed class AnalyzeSettings : WorkspaceSettings +{ + [CommandOption("-a|--audit")] + [Description("Check every package against published security advisories.")] + public bool Audit { get; init; } + + [CommandOption("--outdated")] + [Description("Report packages that are behind the feed.")] + public bool Outdated { get; init; } + + [CommandOption("--deprecated")] + [Description("Report packages the author marked deprecated.")] + public bool Deprecated { get; init; } + + [CommandOption("--licenses")] + [Description("Flag copyleft, proprietary and undeclared licences.")] + public bool Licenses { get; init; } + + [CommandOption("--transitive")] + [Description("Resolve the transitive graph (requires the dotnet CLI).")] + public bool Transitive { get; init; } + + [CommandOption("--all-checks")] + [Description("Shorthand for --audit --outdated --deprecated --licenses --transitive.")] + public bool AllChecks { get; init; } + + [CommandOption("--max-parallelism ")] + [Description("How many packages to query concurrently. Default: 8.")] + public int? MaxParallelism { get; init; } + + [CommandOption("--fail-on ")] + [Description("Exit non-zero at or above this severity: Info, Low, Moderate, High, Critical, Never.")] + public string? FailOn { get; init; } + + [CommandOption("--rules ")] + [Description("Override a rule's severity, or disable it with Rule=none. Repeatable.")] + public string[]? Rules { get; init; } + + [CommandOption("--baseline ")] + [Description("Suppress findings recorded in this baseline file.")] + public string? Baseline { get; init; } + + [CommandOption("--write-baseline")] + [Description("Record the current findings as the baseline and exit successfully.")] + public bool WriteBaseline { get; init; } + + [CommandOption("--ignore ")] + [Description("Ignore a package entirely. Supports a trailing wildcard. Repeatable.")] + public string[]? Ignore { get; init; } + + [CommandOption("--include-prerelease")] + [Description("Treat pre-release versions as acceptable upgrade targets.")] + public bool IncludePrerelease { get; init; } + + [CommandOption("--output ")] + [Description("Terminal (default), Json, Sarif, Markdown or Csv.")] + public string? Output { get; init; } + + [CommandOption("--output-file ")] + [Description("Write the structured report to a file instead of stdout.")] + public string? OutputFile { get; init; } + + [CommandOption("--fix")] + [Description("Apply every auto-fixable finding.")] + public bool Fix { get; init; } + + [CommandOption("--fix-dry-run")] + [Description("Show what --fix would change without writing.")] + public bool FixDryRun { get; init; } + + [CommandOption("--no-backup")] + [Description("Do not back up before applying fixes.")] + public bool NoBackup { get; init; } + + [CommandOption("--backup-dir ")] + [Description("Where fix backups are stored.")] + public string? BackupDir { get; init; } +} + +/// Runs the dependency health analysis and, optionally, its auto-fixes. +public sealed class AnalyzeCommand( + IAnalysisEngine engine, + IAutoFixService autoFixService, + IBaselineService baselineService, + EffectiveSettings effective +) : AsyncCommand +{ + public override async Task ExecuteAsync(CommandContext context, AnalyzeSettings settings) + { + Ui.Quiet = settings.Quiet; + Ui.Verbose = settings.Verbose; + + await effective.LoadAsync(settings); + + var format = ParseFormat(settings.Output); + + // Structured output goes to stdout, so progress chatter must not pollute it. + if (format != ReportFormat.Terminal && settings.OutputFile is null) + { + Ui.Quiet = true; + } + + var options = BuildOptions(settings); + var report = await RunAsync(options, format); + + if (report.ProjectCount == 0) + { + Ui.Warn("No project files were found."); + return ExitCode.NoProjectsFound; + } + + if (settings.WriteBaseline) + { + var path = settings.Baseline ?? Path.Combine(options.RootDirectory, ".centralconfig-baseline.json"); + await baselineService.WriteAsync(path, report.Findings); + Ui.Success($"Baseline written to {ReportRenderer.Relative(path)} ({report.Findings.Count} finding(s))."); + return ExitCode.Success; + } + + await EmitAsync(report, format, settings.OutputFile); + + if (settings.Fix || settings.FixDryRun) + { + var fixExit = await ApplyFixesAsync(options, report, settings); + if (fixExit != ExitCode.Success) + { + return fixExit; + } + } + + if (report.IsIncomplete && report.Findings.Count == 0) + { + return ExitCode.IncompleteAnalysis; + } + + var failOn = ParseSeverity(settings.FailOn) ?? effective.File?.FailOn ?? Severity.Info; + + if (failOn != Severity.Never && report.HighestSeverity >= failOn && report.Findings.Count > 0) + { + return ExitCode.AnalysisIssuesFound; + } + + return ExitCode.Success; + } + + private async Task RunAsync(AnalysisOptions options, ReportFormat format) + { + if (Ui.Quiet || format != ReportFormat.Terminal) + { + return await engine.AnalyzeAsync(options); + } + + return await Ui.StatusAsync( + "Analysing dependencies…", + async update => await engine.AnalyzeAsync(options, new Progress(update)) + ); + } + + private async Task ApplyFixesAsync( + AnalysisOptions options, + AnalysisReport report, + AnalyzeSettings settings + ) + { + var fixable = report.Findings.Where(f => f.IsAutoFixable).ToList(); + + if (fixable.Count == 0) + { + Ui.Info("[grey]Nothing to auto-fix.[/]"); + return ExitCode.Success; + } + + var analysisContext = await engine.BuildContextAsync(options); + var result = await autoFixService.PlanAsync(analysisContext, fixable); + + if (!result.HasChanges) + { + Ui.Info("[grey]The auto-fixable findings produced no file changes.[/]"); + return ExitCode.Success; + } + + Ui.Blank(); + Ui.Rule("Auto-fix"); + + foreach (var change in result.Changes) + { + Ui.Info($"[yellow]modify[/] {Markup.Escape(ReportRenderer.Relative(change.Path))}"); + + if (settings.Verbose || settings.FixDryRun) + { + ReportRenderer.RenderDiff(change.Diff()); + } + } + + if (settings.FixDryRun) + { + Ui.Info($"[grey]Dry run: {result.Fixed.Count} finding(s) would be fixed.[/]"); + return ExitCode.Success; + } + + var backup = await autoFixService.ApplyAsync( + result, + options.RootDirectory, + settings.BackupDir, + createBackup: !settings.NoBackup + ); + + Ui.Success($"Fixed {result.Fixed.Count} finding(s) across {result.Changes.Count} file(s)."); + + if (backup is not null) + { + Ui.Info($"[grey]Backup {backup.Id} created.[/]"); + } + + return ExitCode.Success; + } + + private static async Task EmitAsync( + AnalysisReport report, + ReportFormat format, + string? outputFile + ) + { + if (format == ReportFormat.Terminal) + { + ReportRenderer.RenderAnalysis(report); + return; + } + + var content = ReportWriterFactory.Create(format).Write(report); + + if (outputFile is null) + { + Console.Out.Write(content); + return; + } + + var directory = Path.GetDirectoryName(Path.GetFullPath(outputFile)); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + await File.WriteAllTextAsync(outputFile, content); + Ui.Success($"Report written to {ReportRenderer.Relative(outputFile)}."); + } + + private AnalysisOptions BuildOptions(AnalyzeSettings settings) + { + var all = settings.AllChecks; + + return new AnalysisOptions + { + RootDirectory = settings.ResolvedDirectory, + SolutionPath = settings.Solution, + ProjectPath = settings.Project, + ExcludePattern = effective.Text(settings.ExcludeDirs, c => c.ExcludeDirs), + Audit = all || settings.Audit, + Outdated = all || settings.Outdated, + Deprecated = all || settings.Deprecated, + Licenses = all || settings.Licenses, + Transitive = all || settings.Transitive, + MaxParallelism = settings.MaxParallelism ?? 8, + FailOn = ParseSeverity(settings.FailOn) ?? effective.File?.FailOn ?? Severity.Info, + RuleOverrides = effective.RuleOverrides(settings.Rules), + BaselinePath = settings.WriteBaseline + ? null + : effective.Text(settings.Baseline, c => c.Baseline), + IgnorePackages = (settings.Ignore ?? []).Concat(effective.File?.IgnorePackages ?? []).ToList(), + IncludePrerelease = settings.IncludePrerelease, + }; + } + + private static ReportFormat ParseFormat(string? value) => + Enum.TryParse(value, true, out var parsed) ? parsed : ReportFormat.Terminal; + + private static Severity? ParseSeverity(string? value) => + Enum.TryParse(value, true, out var parsed) ? parsed : null; +} diff --git a/CentralConfigGenerator/Cli/Commands/BackupCommands.cs b/CentralConfigGenerator/Cli/Commands/BackupCommands.cs new file mode 100644 index 0000000..f08ecfc --- /dev/null +++ b/CentralConfigGenerator/Cli/Commands/BackupCommands.cs @@ -0,0 +1,190 @@ +using System.ComponentModel; +using CentralConfigGenerator.Cli.Infrastructure; +using CentralConfigGenerator.Core.Models; +using CentralConfigGenerator.Core.Services.Abstractions; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace CentralConfigGenerator.Cli.Commands; + +public class BackupSettings : CommandSettings +{ + [CommandOption("-d|--directory ")] + [Description("Workspace directory holding the backups. Defaults to the current directory.")] + public string? Directory { get; init; } + + [CommandOption("--backup-dir ")] + [Description("Explicit backup directory, when it is not next to the workspace.")] + public string? BackupDir { get; init; } + + [CommandOption("-q|--quiet")] + public bool Quiet { get; init; } + + public string ResolvedBackupDirectory => + System.IO.Path.GetFullPath( + BackupDir ?? Directory ?? System.IO.Directory.GetCurrentDirectory() + ); +} + +/// Lists every backup this tool has taken. +public sealed class BackupListCommand(IBackupService backupService) : Command +{ + public override int Execute(CommandContext context, BackupSettings settings) + { + Ui.Quiet = settings.Quiet; + + var backups = backupService.List(settings.ResolvedBackupDirectory); + + if (backups.Count == 0) + { + Ui.Info("[grey]No backups found.[/]"); + return ExitCode.Success; + } + + var table = new Table().Border(TableBorder.Rounded); + table.AddColumn("Id"); + table.AddColumn("Created"); + table.AddColumn("Operation"); + table.AddColumn(new TableColumn("Files").RightAligned()); + table.AddColumn(new TableColumn("Size").RightAligned()); + + foreach (var backup in backups) + { + table.AddRow( + Markup.Escape(backup.Id), + backup.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"), + Markup.Escape(backup.Operation), + backup.Files.Count.ToString(), + FormatSize(backup.SizeInBytes) + ); + } + + AnsiConsole.Write(table); + return ExitCode.Success; + } + + private static string FormatSize(long bytes) => + bytes switch + { + < 1024 => $"{bytes} B", + < 1024 * 1024 => $"{bytes / 1024.0:F1} KB", + _ => $"{bytes / (1024.0 * 1024.0):F1} MB", + }; +} + +public sealed class BackupRestoreSettings : BackupSettings +{ + [CommandArgument(0, "[BACKUP_ID]")] + [Description("Backup to restore. Defaults to the most recent one.")] + public string? BackupId { get; init; } + + [CommandOption("-f|--force")] + [Description("Skip the confirmation prompt.")] + public bool Force { get; init; } +} + +/// Restores the files captured in a backup, undoing whatever changed them. +public sealed class BackupRestoreCommand(IBackupService backupService) + : AsyncCommand +{ + public override async Task ExecuteAsync( + CommandContext context, + BackupRestoreSettings settings + ) + { + Ui.Quiet = settings.Quiet; + + var directory = settings.ResolvedBackupDirectory; + var backups = backupService.List(directory); + + if (backups.Count == 0) + { + Ui.Error("No backups found to restore."); + return ExitCode.FileOperationError; + } + + var target = settings.BackupId is null + ? backups[0] + : backups.FirstOrDefault(b => + string.Equals(b.Id, settings.BackupId, StringComparison.OrdinalIgnoreCase) + ); + + if (target is null) + { + Ui.Error($"Backup '{settings.BackupId}' was not found."); + return ExitCode.ValidationError; + } + + Ui.Info( + $"Restoring backup [bold]{Markup.Escape(target.Id)}[/] " + + $"({target.Files.Count} file(s), taken {target.CreatedAt.ToLocalTime():yyyy-MM-dd HH:mm:ss} by '{Markup.Escape(target.Operation)}')." + ); + + if (!settings.Force && !Ui.Confirm("This overwrites the current files. Continue?", defaultValue: false)) + { + Ui.Warn("Cancelled."); + return ExitCode.Success; + } + + var restored = await backupService.RestoreAsync(directory, target.Id); + + if (restored is null) + { + Ui.Error("The backup manifest could not be read."); + return ExitCode.FileOperationError; + } + + Ui.Success($"Restored {restored.Files.Count} file(s) from backup {restored.Id}."); + return ExitCode.Success; + } +} + +public sealed class BackupPruneSettings : BackupSettings +{ + [CommandOption("--retention ")] + [Description("How many backups to keep. Default: 5.")] + public int? Retention { get; init; } + + [CommandOption("--all")] + [Description("Delete every backup.")] + public bool All { get; init; } + + [CommandOption("-f|--force")] + public bool Force { get; init; } +} + +/// Deletes old backup sets. +public sealed class BackupPruneCommand(IBackupService backupService) : Command +{ + public override int Execute(CommandContext context, BackupPruneSettings settings) + { + Ui.Quiet = settings.Quiet; + + var directory = settings.ResolvedBackupDirectory; + var retention = settings.All ? 0 : settings.Retention ?? 5; + var existing = backupService.List(directory); + var doomed = existing.Skip(retention).ToList(); + + if (doomed.Count == 0) + { + Ui.Info($"[grey]Nothing to prune ({existing.Count} backup(s), keeping {retention}).[/]"); + return ExitCode.Success; + } + + if ( + !settings.Force + && !Ui.Confirm($"Delete {doomed.Count} backup set(s)? This cannot be undone.", defaultValue: false) + ) + { + Ui.Warn("Cancelled."); + return ExitCode.Success; + } + + var removed = settings.All + ? backupService.PruneAll(directory) + : backupService.Prune(directory, retention); + + Ui.Success($"Deleted {removed.Count} backup set(s)."); + return ExitCode.Success; + } +} diff --git a/CentralConfigGenerator/Cli/Commands/BatchCommand.cs b/CentralConfigGenerator/Cli/Commands/BatchCommand.cs new file mode 100644 index 0000000..5e89df8 --- /dev/null +++ b/CentralConfigGenerator/Cli/Commands/BatchCommand.cs @@ -0,0 +1,159 @@ +using System.ComponentModel; +using CentralConfigGenerator.Cli.Infrastructure; +using CentralConfigGenerator.Cli.Rendering; +using CentralConfigGenerator.Core.Migration; +using CentralConfigGenerator.Core.Models; +using CentralConfigGenerator.Core.Workspace; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace CentralConfigGenerator.Cli.Commands; + +public sealed class BatchSettings : MutatingSettings +{ + [CommandArgument(0, "[ROOT]")] + [Description("Root directory containing several repositories or solutions.")] + public string? Root { get; init; } + + [CommandOption("--parallel")] + [Description("Process workspaces concurrently.")] + public bool Parallel { get; init; } + + [CommandOption("--continue")] + [Description("Keep going after a workspace fails.")] + public bool ContinueOnError { get; init; } + + [CommandOption("--max-depth ")] + [Description("How deep to search for solutions. Default: 4.")] + public int? MaxDepth { get; init; } + + [CommandOption("--list")] + [Description("List the workspaces that would be processed, then stop.")] + public bool List { get; init; } + + [CommandOption("-t|--transitive-pinning")] + [Description("Enable CentralPackageTransitivePinningEnabled in every generated file.")] + public bool TransitivePinning { get; init; } +} + +/// Runs the migration across every solution in a monorepo. +public sealed class BatchCommand( + BatchService batchService, + IMigrationService migrationService, + EffectiveSettings effective +) : AsyncCommand +{ + public override async Task ExecuteAsync(CommandContext context, BatchSettings settings) + { + Ui.Quiet = settings.Quiet; + Ui.Verbose = settings.Verbose; + + await effective.LoadAsync(settings); + + var root = Path.GetFullPath(settings.Root ?? settings.ResolvedDirectory); + + var batchOptions = new BatchOptions + { + RootDirectory = root, + Parallel = settings.Parallel, + ContinueOnError = settings.ContinueOnError, + ExcludePattern = effective.Text(settings.ExcludeDirs, c => c.ExcludeDirs), + MaxDepth = settings.MaxDepth ?? 4, + }; + + var workspaces = batchService.DiscoverWorkspaces(batchOptions); + + if (workspaces.Count == 0) + { + Ui.Warn($"No solutions or projects found beneath {ReportRenderer.Relative(root)}."); + return ExitCode.NoProjectsFound; + } + + Ui.Info($"Found [bold]{workspaces.Count}[/] workspace(s)."); + + if (settings.List) + { + foreach (var workspace in workspaces) + { + Ui.Info($" {Markup.Escape(ReportRenderer.Relative(workspace))}"); + } + + return ExitCode.Success; + } + + if ( + !settings.Force + && !settings.DryRun + && !Ui.Confirm($"Migrate all {workspaces.Count} workspace(s)?", defaultValue: false) + ) + { + Ui.Warn("Cancelled."); + return ExitCode.Success; + } + + var result = await batchService.RunAsync( + batchOptions, + async (workspace, token) => + { + var options = new MigrationOptions + { + RootDirectory = workspace, + ExcludePattern = batchOptions.ExcludePattern, + Merge = true, + TransitivePinning = effective.Bool( + settings.TransitivePinning, + c => c.TransitivePinning + ), + CreateBackup = !settings.NoBackup && effective.File?.Backup != false, + BackupDirectory = settings.BackupDir, + }; + + var plan = await migrationService.PlanAsync(options, null, token); + + if (!settings.DryRun && plan.HasChanges) + { + await migrationService.ApplyAsync(plan, options, token); + } + + return plan; + }, + new Progress(workspace => + Ui.Detail($"→ {ReportRenderer.Relative(workspace)}") + ) + ); + + var table = new Table().Border(TableBorder.Rounded); + table.AddColumn("Workspace"); + table.AddColumn(new TableColumn("Projects").RightAligned()); + table.AddColumn(new TableColumn("Packages").RightAligned()); + table.AddColumn(new TableColumn("Changes").RightAligned()); + table.AddColumn("Result"); + + foreach (var item in result.Items) + { + table.AddRow( + Markup.Escape(ReportRenderer.Relative(item.Workspace)), + item.Plan?.ProjectCount.ToString() ?? "-", + item.Plan?.PackageCount.ToString() ?? "-", + item.Plan?.Changes.Count.ToString() ?? "-", + item.Succeeded + ? settings.DryRun + ? "[grey]dry run[/]" + : "[green]ok[/]" + : $"[red]{Markup.Escape(item.Error ?? "failed")}[/]" + ); + } + + AnsiConsole.Write(table); + Ui.Blank(); + + if (result.FailedCount > 0) + { + Ui.Error($"{result.FailedCount} workspace(s) failed, {result.SucceededCount} succeeded."); + return ExitCode.UnexpectedError; + } + + Ui.Success($"{result.SucceededCount} workspace(s) processed."); + return ExitCode.Success; + } +} diff --git a/CentralConfigGenerator/Cli/Commands/BuildPropsCommand.cs b/CentralConfigGenerator/Cli/Commands/BuildPropsCommand.cs new file mode 100644 index 0000000..bc647a5 --- /dev/null +++ b/CentralConfigGenerator/Cli/Commands/BuildPropsCommand.cs @@ -0,0 +1,127 @@ +using System.ComponentModel; +using CentralConfigGenerator.Cli.Infrastructure; +using CentralConfigGenerator.Cli.Rendering; +using CentralConfigGenerator.Core.Migration; +using CentralConfigGenerator.Core.Models; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace CentralConfigGenerator.Cli.Commands; + +public sealed class BuildPropsSettings : MutatingSettings +{ + [CommandOption("-o|--output-dir ")] + [Description("Where Directory.Build.props is written. Defaults to the scanned directory.")] + public string? OutputDir { get; init; } + + [CommandOption("--overwrite")] + [Description("Replace an existing Directory.Build.props.")] + public bool Overwrite { get; init; } + + [CommandOption("-m|--merge")] + [Description("Merge into an existing Directory.Build.props.")] + public bool Merge { get; init; } + + [CommandOption("--property ")] + [Description("Property to consider for hoisting. Repeatable; replaces the default list.")] + public string[]? Properties { get; init; } + + [CommandOption("--keep-in-projects")] + [Description("Write Directory.Build.props but leave the projects untouched.")] + public bool KeepInProjects { get; init; } +} + +/// Hoists MSBuild properties shared by every project into Directory.Build.props. +public sealed class BuildPropsCommand( + IMigrationService migrationService, + EffectiveSettings effective +) : AsyncCommand +{ + public override async Task ExecuteAsync(CommandContext context, BuildPropsSettings settings) + { + Ui.Quiet = settings.Quiet; + Ui.Verbose = settings.Verbose; + + await effective.LoadAsync(settings); + + var options = new MigrationOptions + { + RootDirectory = settings.ResolvedDirectory, + SolutionPath = settings.Solution, + ProjectPath = settings.Project, + OutputDirectory = settings.OutputDir, + ExcludePattern = effective.Text(settings.ExcludeDirs, c => c.ExcludeDirs), + Merge = settings.Merge, + // Only the property half of the migration is wanted here. + KeepInlineVersions = true, + UnifyProperties = true, + CreateBackup = !settings.NoBackup && effective.File?.Backup != false, + BackupDirectory = effective.Text(settings.BackupDir, c => c.BackupDir), + AddGitIgnore = effective.Bool(settings.AddGitIgnore, c => c.AddGitignore), + BuildProperties = settings.Properties + ?? effective.File?.BuildProperties + ?? BuildPropertyDefaults.Recommended, + }; + + if (!settings.Merge && !settings.Overwrite && !settings.DryRun && File.Exists(options.BuildPropsPath)) + { + Ui.Warn( + "Directory.Build.props already exists. Use --merge to combine, or --overwrite to replace it." + ); + return ExitCode.ValidationError; + } + + var plan = await Ui.StatusAsync( + "Looking for shared properties…", + async _ => await migrationService.PlanAsync(options) + ); + + if (plan.ProjectCount == 0) + { + Ui.Warn("No project files were found."); + return ExitCode.NoProjectsFound; + } + + // Drop the Directory.Packages.props change; this command is about properties only. + var changes = plan + .Changes.Where(c => + !string.Equals(c.Path, options.PackagesPropsPath, StringComparison.OrdinalIgnoreCase) + ) + .Where(c => + !settings.KeepInProjects + || string.Equals(c.Path, options.BuildPropsPath, StringComparison.OrdinalIgnoreCase) + ) + .ToList(); + + if (changes.Count == 0) + { + Ui.Warn( + "No property is declared identically by two or more projects, so nothing can be hoisted safely." + ); + return ExitCode.Success; + } + + var trimmed = plan with { Changes = changes }; + + ReportRenderer.RenderPlan(trimmed, settings.Diff, options.RootDirectory); + + if (settings.DryRun) + { + Ui.Blank(); + Ui.Info("[grey]Dry run: no files were written.[/]"); + return ExitCode.Success; + } + + var backup = await migrationService.ApplyAsync(trimmed, options); + + Ui.Blank(); + Ui.Success($"Wrote {changes.Count} file(s)."); + + if (backup is not null) + { + Ui.Info($"[grey]Backup {backup.Id} created.[/]"); + } + + return ExitCode.Success; + } +} diff --git a/CentralConfigGenerator/Cli/Commands/CompletionsCommand.cs b/CentralConfigGenerator/Cli/Commands/CompletionsCommand.cs new file mode 100644 index 0000000..3821c03 --- /dev/null +++ b/CentralConfigGenerator/Cli/Commands/CompletionsCommand.cs @@ -0,0 +1,181 @@ +using System.ComponentModel; +using CentralConfigGenerator.Cli.Infrastructure; +using CentralConfigGenerator.Core.Models; +using Spectre.Console.Cli; + +namespace CentralConfigGenerator.Cli.Commands; + +public sealed class CompletionsSettings : CommandSettings +{ + [CommandArgument(0, "")] + [Description("bash, zsh, fish or powershell.")] + public string Shell { get; init; } = string.Empty; +} + +/// Emits a shell completion script for the tool. +public sealed class CompletionsCommand : Command +{ + private static readonly string[] Commands = + [ + "migrate", + "packages", + "build", + "all", + "revert", + "analyze", + "update", + "verify", + "tree", + "status", + "doctor", + "init", + "explain", + "batch", + "backups", + "completions", + ]; + + private static readonly string[] Options = + [ + "--directory", + "--solution", + "--project", + "--exclude-dirs", + "--dry-run", + "--diff", + "--merge", + "--overwrite", + "--keep-attrs", + "--conflict-strategy", + "--interactive-conflicts", + "--ignore-prerelease", + "--transitive-pinning", + "--encoding", + "--linewrap", + "--unify-props", + "--audit", + "--outdated", + "--deprecated", + "--licenses", + "--transitive", + "--all-checks", + "--fail-on", + "--rules", + "--baseline", + "--write-baseline", + "--output", + "--output-file", + "--fix", + "--fix-dry-run", + "--bisect", + "--bisect-budget", + "--only", + "--no-backup", + "--backup-dir", + "--add-gitignore", + "--force", + "--quiet", + "--verbose", + "--help", + ]; + + public override int Execute(CommandContext context, CompletionsSettings settings) + { + var script = Render(settings.Shell); + + if (script is null) + { + Ui.Error($"Unsupported shell '{settings.Shell}'. Choose bash, zsh, fish or powershell."); + return ExitCode.ValidationError; + } + + Console.Out.Write(script); + return ExitCode.Success; + } + + /// Returns the completion script for a shell, or null when it is not supported. + internal static string? Render(string shell) => + shell.ToLowerInvariant() switch + { + "bash" => Bash(), + "zsh" => Zsh(), + "fish" => Fish(), + "powershell" or "pwsh" => PowerShell(), + _ => null, + }; + + private static string Bash() => + """ + # central-config bash completion + # Install: central-config completions bash > /etc/bash_completion.d/central-config + _central_config_completions() + { + local cur commands options + cur="${COMP_WORDS[COMP_CWORD]}" + commands="__COMMANDS__" + options="__OPTIONS__" + + if [[ ${COMP_CWORD} -eq 1 ]]; then + COMPREPLY=( $(compgen -W "${commands}" -- "${cur}") ) + else + COMPREPLY=( $(compgen -W "${options}" -- "${cur}") ) + fi + return 0 + } + complete -F _central_config_completions central-config + + """ + .Replace("__COMMANDS__", string.Join(" ", Commands)) + .Replace("__OPTIONS__", string.Join(" ", Options)); + + private static string Zsh() => + """ + #compdef central-config + # Install: central-config completions zsh > "${fpath[1]}/_central-config" + _central_config() { + local -a commands options + commands=(__COMMANDS__) + options=(__OPTIONS__) + + if (( CURRENT == 2 )); then + _describe 'command' commands + else + _describe 'option' options + fi + } + compdef _central_config central-config + + """ + .Replace("__COMMANDS__", string.Join(" ", Commands)) + .Replace("__OPTIONS__", string.Join(" ", Options)); + + private static string Fish() => + string.Join( + "\n", + new[] { "# central-config fish completion", "# Install: central-config completions fish > ~/.config/fish/completions/central-config.fish" } + .Concat( + Commands.Select(c => + $"complete -c central-config -n '__fish_use_subcommand' -a '{c}'" + ) + ) + .Concat( + Options.Select(o => $"complete -c central-config -l '{o.TrimStart('-')}'") + ) + ) + "\n"; + + private static string PowerShell() => + $$""" + # central-config PowerShell completion + # Install: central-config completions powershell | Out-String | Invoke-Expression + Register-ArgumentCompleter -Native -CommandName central-config -ScriptBlock { + param($wordToComplete, $commandAst, $cursorPosition) + $commands = @({{string.Join(", ", Commands.Select(c => $"'{c}'"))}}) + $options = @({{string.Join(", ", Options.Select(o => $"'{o}'"))}}) + $candidates = if ($commandAst.CommandElements.Count -le 2) { $commands + $options } else { $options } + $candidates | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { + [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) + } + } + + """; +} diff --git a/CentralConfigGenerator/Cli/Commands/DoctorCommand.cs b/CentralConfigGenerator/Cli/Commands/DoctorCommand.cs new file mode 100644 index 0000000..a4dcf6d --- /dev/null +++ b/CentralConfigGenerator/Cli/Commands/DoctorCommand.cs @@ -0,0 +1,65 @@ +using CentralConfigGenerator.Cli.Infrastructure; +using CentralConfigGenerator.Core.Models; +using CentralConfigGenerator.Core.Workspace; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace CentralConfigGenerator.Cli.Commands; + +public sealed class DoctorSettings : WorkspaceSettings; + +/// Environment diagnostics: run this first when something behaves unexpectedly. +public sealed class DoctorCommand(DoctorService doctorService) : AsyncCommand +{ + public override async Task ExecuteAsync(CommandContext context, DoctorSettings settings) + { + Ui.Quiet = settings.Quiet; + Ui.Verbose = settings.Verbose; + + var report = await Ui.StatusAsync( + "Running diagnostics…", + async _ => await doctorService.RunAsync(settings.ResolvedDirectory) + ); + + var table = new Table().Border(TableBorder.Rounded).Expand(); + table.AddColumn(new TableColumn("").Width(3)); + table.AddColumn("Check"); + table.AddColumn("Result"); + + foreach (var check in report.Checks) + { + var detail = check.Remedy is null + ? Markup.Escape(check.Detail) + : $"{Markup.Escape(check.Detail)}\n[grey]→ {Markup.Escape(check.Remedy)}[/]"; + + table.AddRow(Icon(check.Status), Markup.Escape(check.Name), detail); + } + + AnsiConsole.Write(table); + + if (report.HasFailures) + { + Ui.Blank(); + Ui.Error("One or more checks failed. Fix those before migrating."); + return ExitCode.ValidationError; + } + + Ui.Blank(); + Ui.Success( + report.HasWarnings + ? "No blocking problems, but some checks have suggestions." + : "Everything looks good." + ); + + return ExitCode.Success; + } + + private static string Icon(CheckStatus status) => + status switch + { + CheckStatus.Pass => "[green]✓[/]", + CheckStatus.Warn => "[yellow]![/]", + CheckStatus.Fail => "[red]✗[/]", + _ => "[grey]-[/]", + }; +} diff --git a/CentralConfigGenerator/Cli/Commands/ExplainCommand.cs b/CentralConfigGenerator/Cli/Commands/ExplainCommand.cs new file mode 100644 index 0000000..6f5d8e5 --- /dev/null +++ b/CentralConfigGenerator/Cli/Commands/ExplainCommand.cs @@ -0,0 +1,84 @@ +using System.ComponentModel; +using CentralConfigGenerator.Cli.Infrastructure; +using CentralConfigGenerator.Cli.Rendering; +using CentralConfigGenerator.Core.Analysis; +using CentralConfigGenerator.Core.Models; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace CentralConfigGenerator.Cli.Commands; + +public sealed class ExplainSettings : CommandSettings +{ + [CommandArgument(0, "[RULE]")] + [Description("Rule id to explain, or 'all' to list every rule.")] + public string? Rule { get; init; } +} + +/// Documents what a rule looks for and why it matters. +public sealed class ExplainCommand : Command +{ + public override int Execute(CommandContext context, ExplainSettings settings) + { + var rules = RuleCatalog.CreateAll(); + + if (settings.Rule is null || string.Equals(settings.Rule, "all", StringComparison.OrdinalIgnoreCase)) + { + var table = new Table().Border(TableBorder.Rounded).Expand(); + table.AddColumn("Rule"); + table.AddColumn("Default"); + table.AddColumn("What it finds"); + table.AddColumn(new TableColumn("Needs").Centered()); + + foreach (var rule in rules) + { + table.AddRow( + Markup.Escape(rule.RuleId), + ReportRenderer.Colorize(rule.DefaultSeverity), + Markup.Escape(rule.Description), + rule.RequiresNetwork ? "[yellow]feed[/]" : "[grey]-[/]" + ); + } + + AnsiConsole.Write(table); + AnsiConsole.MarkupLine( + "\n[grey]Run 'central-config explain ' for the full rationale, " + + "or set severities with --rules RuleId=Severity (or =none to disable).[/]" + ); + + return ExitCode.Success; + } + + var match = RuleCatalog.Find(settings.Rule); + + if (match is null) + { + Ui.Error($"Unknown rule '{settings.Rule}'."); + + var suggestion = rules + .Select(r => r.RuleId) + .FirstOrDefault(id => id.Contains(settings.Rule, StringComparison.OrdinalIgnoreCase)); + + if (suggestion is not null) + { + Ui.Info($"Did you mean [bold]{suggestion}[/]?"); + } + + Ui.Info("[grey]Run 'central-config explain all' to list every rule.[/]"); + return ExitCode.ValidationError; + } + + var grid = new Grid().AddColumn(new GridColumn().NoWrap().PadRight(2)).AddColumn(); + grid.AddRow("[bold]Rule[/]", Markup.Escape(match.RuleId)); + grid.AddRow("[bold]Title[/]", Markup.Escape(match.Title)); + grid.AddRow("[bold]Default severity[/]", ReportRenderer.Colorize(match.DefaultSeverity)); + grid.AddRow("[bold]Needs the feed[/]", match.RequiresNetwork ? "yes" : "no"); + grid.AddRow("[bold]Needs CPM[/]", match.RequiresCentralPackageManagement ? "yes" : "no"); + grid.AddRow("[bold]Detects[/]", Markup.Escape(match.Description)); + grid.AddRow("[bold]Why it matters[/]", Markup.Escape(match.Rationale)); + + AnsiConsole.Write(new Panel(grid).Header($"[bold]{Markup.Escape(match.Title)}[/]").Border(BoxBorder.Rounded)); + + return ExitCode.Success; + } +} diff --git a/CentralConfigGenerator/Cli/Commands/InitCommand.cs b/CentralConfigGenerator/Cli/Commands/InitCommand.cs new file mode 100644 index 0000000..9053e0f --- /dev/null +++ b/CentralConfigGenerator/Cli/Commands/InitCommand.cs @@ -0,0 +1,47 @@ +using System.ComponentModel; +using CentralConfigGenerator.Cli.Infrastructure; +using CentralConfigGenerator.Cli.Rendering; +using CentralConfigGenerator.Core.Configuration; +using CentralConfigGenerator.Core.Models; +using Spectre.Console.Cli; + +namespace CentralConfigGenerator.Cli.Commands; + +public sealed class InitSettings : CommandSettings +{ + [CommandOption("-d|--directory ")] + [Description("Where the config file is written. Defaults to the current directory.")] + public string? Directory { get; init; } + + [CommandOption("-f|--force")] + [Description("Overwrite an existing .centralconfig.json.")] + public bool Force { get; init; } + + [CommandOption("-q|--quiet")] + public bool Quiet { get; init; } +} + +/// Scaffolds a .centralconfig.json so the team shares one set of defaults. +public sealed class InitCommand(ConfigurationLoader loader) : AsyncCommand +{ + public override async Task ExecuteAsync(CommandContext context, InitSettings settings) + { + Ui.Quiet = settings.Quiet; + + var directory = Path.GetFullPath(settings.Directory ?? System.IO.Directory.GetCurrentDirectory()); + var path = Path.Combine(directory, ConfigurationLoader.FileName); + + if (File.Exists(path) && !settings.Force) + { + Ui.Warn($"{ConfigurationLoader.FileName} already exists. Use --force to overwrite."); + return ExitCode.ValidationError; + } + + var written = await loader.ScaffoldAsync(directory, settings.Force); + + Ui.Success($"Created {ReportRenderer.Relative(written)}."); + Ui.Info("[grey]Edit it to set your conflict strategy, rule severities and backup retention.[/]"); + + return ExitCode.Success; + } +} diff --git a/CentralConfigGenerator/Cli/Commands/MigrateCommand.cs b/CentralConfigGenerator/Cli/Commands/MigrateCommand.cs new file mode 100644 index 0000000..399a64a --- /dev/null +++ b/CentralConfigGenerator/Cli/Commands/MigrateCommand.cs @@ -0,0 +1,253 @@ +using System.ComponentModel; +using CentralConfigGenerator.Cli.Infrastructure; +using CentralConfigGenerator.Cli.Rendering; +using CentralConfigGenerator.Core.Migration; +using CentralConfigGenerator.Core.Models; +using CentralConfigGenerator.Core.Services; +using NuGet.Versioning; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace CentralConfigGenerator.Cli.Commands; + +public class MigrateSettings : MutatingSettings +{ + [CommandOption("-o|--output-dir ")] + [Description("Where Directory.Packages.props is written. Defaults to the scanned directory.")] + public string? OutputDir { get; init; } + + [CommandOption("-m|--merge")] + [Description("Merge into an existing Directory.Packages.props instead of replacing it.")] + public bool Merge { get; init; } + + [CommandOption("--overwrite")] + [Description("Replace an existing Directory.Packages.props.")] + public bool Overwrite { get; init; } + + [CommandOption("-k|--keep-attrs")] + [Description("Leave inline Version attributes in the project files.")] + public bool KeepAttributes { get; init; } + + [CommandOption("--conflict-strategy ")] + [Description("Highest (default), Lowest, MostCommon or Fail.")] + public string? ConflictStrategy { get; init; } + + [CommandOption("--interactive-conflicts")] + [Description("Prompt for every version conflict instead of resolving automatically.")] + public bool InteractiveConflicts { get; init; } + + [CommandOption("--min-version")] + [Description("Shorthand for --conflict-strategy Lowest.")] + public bool MinVersion { get; init; } + + [CommandOption("--ignore-prerelease")] + [Description("Prefer stable versions when resolving conflicts.")] + public bool IgnorePrerelease { get; init; } + + [CommandOption("-t|--transitive-pinning")] + [Description("Enable CentralPackageTransitivePinningEnabled in the generated file.")] + public bool TransitivePinning { get; init; } + + [CommandOption("-e|--encoding ")] + [Description("Write files with this encoding (IANA name, e.g. utf-8). Default: preserve.")] + public string? Encoding { get; init; } + + [CommandOption("-l|--linewrap