diff --git a/README-DependencyGraph.md b/README-DependencyGraph.md new file mode 100644 index 0000000..f83a509 --- /dev/null +++ b/README-DependencyGraph.md @@ -0,0 +1,258 @@ +# Recursive NuGet Dependency Graph Feature + +This document describes the new recursive NuGet dependency graph functionality added to the `bld` tool, which was implemented based on the existing `NugetMetadataService.GetLatestVersionWithFrameworkCheckAsync` logic. + +## Overview + +The new dependency graph feature recursively enumerates packages in `DependencyGroups` using version ranges to determine all transitively referenced packages. It provides both a hierarchical graph structure and a flat list of all packages in the dependency tree. + +## Key Features + +### Efficient Caching +- **Request Caching**: Packages are cached to avoid duplicate NuGet API requests +- **Pre-population**: Existing package references from `OutdatedService` are pre-cached to minimize redundant network calls +- **Smart Deduplication**: The same package referenced multiple times is fetched only once + +### Graph Structure +- **Tree Representation**: `DependencyGraphNode` objects form a hierarchical tree +- **Flat List**: All packages are also provided in a flattened `PackageReference` collection +- **Metadata Rich**: Each package includes version, target framework, depth, prerelease status, and more + +### Performance Optimizations +- **Parallel Processing**: Uses `Parallel.ForEachAsync` for concurrent package resolution +- **Depth Limiting**: Configurable maximum depth to prevent infinite traversal +- **Cycle Detection**: Built-in cycle detection to handle circular dependencies + +## Architecture + +### Core Components + +#### 1. `DependencyGraphModels.cs` +Contains the data models for representing dependency graphs: + +```csharp +// Represents a single node in the dependency tree +internal record DependencyGraphNode { + public string PackageId { get; init; } + public string Version { get; init; } + public string TargetFramework { get; init; } + public IReadOnlyList Dependencies { get; init; } + public int Depth { get; init; } + // ... more properties +} + +// Complete dependency graph with both tree and flat representations +internal record PackageDependencyGraph { + public IReadOnlyList RootPackages { get; init; } + public IReadOnlyList AllPackages { get; init; } + public IReadOnlyList UnresolvedPackages { get; init; } + // ... analysis properties +} +``` + +#### 2. `RecursiveDependencyResolver.cs` +The core service that performs recursive dependency resolution: + +```csharp +internal class RecursiveDependencyResolver { + // Resolves all transitive dependencies with caching and cycle detection + public async Task ResolveTransitiveDependenciesAsync( + IEnumerable rootPackageIds, + DependencyResolutionOptions options, + Dictionary? existingPackageReferences = null, + CancellationToken cancellationToken = default) +} +``` + +#### 3. `DependencyGraphService.cs` +High-level service that orchestrates dependency graph building and analysis: + +```csharp +internal class DependencyGraphService { + // Builds comprehensive dependency graph from OutdatedService package references + public async Task BuildDependencyGraphAsync( + Dictionary allPackageReferences, + bool includePrerelease = false, + int maxDepth = 5, + CancellationToken cancellationToken = default) + + // Analyzes the graph for patterns and statistics + public DependencyGraphAnalysis AnalyzeDependencyGraph(PackageDependencyGraph graph) +} +``` + +#### 4. `OutdatedServiceExtensions.cs` +Extension methods that integrate with the existing `OutdatedService`: + +```csharp +// Extension method for Dictionary +public static async Task BuildAndShowDependencyGraphAsync( + this Dictionary allPackageReferences, + IConsoleOutput console, + bool includePrerelease = false, + int maxDepth = 5, + bool showAnalysis = true, + CancellationToken cancellationToken = default) +``` + +## Usage Examples + +### Basic Usage in OutdatedService + +The new functionality integrates seamlessly with the existing `OutdatedService`: + +```csharp +// In OutdatedService.cs - new method added +public async Task BuildDependencyGraphAsync( + string rootPath, + bool includePrerelease = false, + int maxDepth = 5, + bool showAnalysis = true, + string? exportPath = null, + CancellationToken cancellationToken = default) +{ + // ... discover packages (similar to CheckOutdatedPackagesAsync) + + // Build dependency graph using extension method + var dependencyGraph = await allPackageReferences.BuildAndShowDependencyGraphAsync( + _console, + includePrerelease, + maxDepth, + showAnalysis, + cancellationToken); + + // Export if requested + if (!string.IsNullOrEmpty(exportPath)) { + await dependencyGraph.ExportDependencyGraphAsync(exportPath, "json", _console); + } + + return 0; +} +``` + +### Direct Usage + +You can also use the components directly: + +```csharp +// Create resolver +var options = new NugetMetadataOptions(); +using var httpClient = NugetMetadataService.CreateHttpClient(options); +var resolver = new RecursiveDependencyResolver(httpClient, options, console); + +// Configure resolution +var resolutionOptions = new DependencyResolutionOptions { + MaxDepth = 5, + AllowPrerelease = false, + TargetFrameworks = new[] { "net8.0" } +}; + +// Resolve dependencies +var dependencyGraph = await resolver.ResolveTransitiveDependenciesAsync( + rootPackageIds, + resolutionOptions, + existingPackageReferences, // Pre-populate cache + cancellationToken); + +// Analyze results +var graphService = new DependencyGraphService(console); +var analysis = graphService.AnalyzeDependencyGraph(dependencyGraph); +``` + +## Integration with Existing Code + +### How it leverages GetLatestVersionWithFrameworkCheckAsync + +The implementation reuses the existing NuGet metadata retrieval logic: + +1. **Same API Calls**: Uses `NugetMetadataService.GetLatestVersionWithFrameworkCheckAsync` for all package lookups +2. **Framework Compatibility**: Leverages the same framework compatibility logic with `FrameworkReducer` and `DefaultCompatibilityProvider` +3. **Dependency Groups**: Recursively processes the `Dependencies` property from `PackageVersionResult.Dependencies` +4. **Version Ranges**: Respects version range constraints from `Dependency.Range` when resolving child packages + +### Cache Integration with OutdatedService + +The resolver intelligently integrates with `OutdatedService.allPackageReferences`: + +```csharp +// Pre-populate cache with existing package references +private async Task PrePopulateCacheAsync( + Dictionary existingPackageReferences, + DependencyResolutionOptions options, + CancellationToken cancellationToken) +{ + // For each existing package, fetch and cache its metadata + // This ensures packages already discovered by OutdatedService are not fetched again +} +``` + +## Output and Analysis + +### Console Output +The functionality provides rich console output including: +- Progress indicators during resolution +- Summary tables showing package counts and statistics +- Dependency analysis with most common packages +- Version conflict detection and reporting +- Performance metrics + +### Export Formats +Dependency graphs can be exported in multiple formats: +- **JSON**: Complete graph structure with all metadata +- **CSV**: Flat list of packages with key properties +- **DOT**: GraphViz format for visualization + +### Analysis Features +- **Package Distribution**: Microsoft vs third-party package breakdown +- **Depth Analysis**: Distribution of packages by dependency depth +- **Common Dependencies**: Most frequently referenced packages across the tree +- **Version Conflicts**: Detection of packages with multiple versions +- **Unresolved Packages**: Tracking of packages that couldn't be resolved + +## Performance Characteristics + +### Efficient Network Usage +- **Caching**: Aggressive caching prevents duplicate API calls +- **Parallel Processing**: Concurrent resolution of independent packages +- **Pre-population**: Reuses existing OutdatedService lookups + +### Memory Efficiency +- **Deduplication**: Packages appearing multiple times are stored once in the flat list +- **Streaming**: Processes packages as they're discovered rather than loading everything upfront +- **Disposal**: Proper disposal of HTTP clients and resources + +### Scalability +- **Depth Limiting**: Prevents runaway recursion in complex dependency trees +- **Cycle Detection**: Handles circular dependencies gracefully +- **Configurable Limits**: Adjustable parallelism and depth limits + +## Error Handling + +The implementation includes comprehensive error handling: +- **Network Failures**: Graceful handling of API timeouts and failures +- **Invalid Packages**: Tracking of packages that cannot be resolved +- **Version Conflicts**: Detection and reporting without stopping resolution +- **Circular Dependencies**: Cycle detection with configurable behavior + +## Future Enhancements + +Potential areas for future improvement: +- **Caching Persistence**: Save cache to disk for subsequent runs +- **Incremental Updates**: Only resolve changed packages +- **Visualization**: Built-in graph visualization capabilities +- **Conflict Resolution**: Automatic resolution of version conflicts +- **Policy Engine**: Configurable policies for dependency selection + +## Testing + +Basic unit tests are provided in `RecursiveDependencyResolverTests.cs`: +- Resolution of simple packages +- Depth limiting validation +- Multiple root package handling +- Cache effectiveness verification + +Integration tests could be added to validate: +- Real-world dependency trees +- Performance characteristics +- Export functionality +- Analysis accuracy \ No newline at end of file diff --git a/TestSln.sln b/TestSln.sln deleted file mode 100644 index a0611eb..0000000 --- a/TestSln.sln +++ /dev/null @@ -1,18 +0,0 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31903.59 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "bld", "bld/bld.csproj", "{12345678-1234-1234-1234-123456789012}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {12345678-1234-1234-1234-123456789012}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {12345678-1234-1234-1234-123456789012}.Debug|Any CPU.Build.0 = Debug|Any CPU - {12345678-1234-1234-1234-123456789012}.Release|Any CPU.ActiveCfg = Release|Any CPU - {12345678-1234-1234-1234-123456789012}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection -EndGlobal \ No newline at end of file diff --git a/bld.Tests/NuGetFrameworkTests.cs b/bld.Tests/NuGetFrameworkTests.cs index 0a958b0..3bbcb11 100644 --- a/bld.Tests/NuGetFrameworkTests.cs +++ b/bld.Tests/NuGetFrameworkTests.cs @@ -1,5 +1,6 @@ ๏ปฟ//using XUnit.Framework; +using bld.Infrastructure; using NuGet.Frameworks; using Xunit.Abstractions; using System.Reflection; @@ -9,12 +10,12 @@ namespace bld.Tests; public class DotNetTests(ITestOutputHelper Console) { [Fact] public void PathCombineLinux() { - Assert.Throws(() => Path.Combine("/mnt/d/tests", null, "child")); + Assert.Throws(() => Path.Combine("/mnt/d/tests", null!, "child")); } [Fact] public void PathCombineWin() { - Assert.Throws(() => Path.Combine("d:\\tests", null, "child")); + Assert.Throws(() => Path.Combine("d:\\tests", null!, "child")); } } @@ -52,9 +53,8 @@ public void IsDotNetCoreFramework_ShouldFilterCorrectly(string tfm, bool expecte } public class NuGetFrameworkTests(ITestOutputHelper Console) { - [Fact] - public void Test1() { - string[] tfms = new[] + + string[] tfms = new[] { ".NETStandard,Version=v2.0", ".NETFramework,Version=v4.7.2", @@ -66,15 +66,28 @@ public void Test1() { ".NETFramework4.6.2", "net8.0", "net9.0", + "net10.0", + "net10", + "net100", "net9", "net9000", "net472x", }; + [Fact] + public void Test1() { + + foreach (var tfm in tfms) { var framework = NuGetFramework.Parse(tfm); + string fx = framework.Framework; string normalizedTfm = framework.GetShortFolderName(); - Console.WriteLine($"Original: {tfm}, Normalized: {normalizedTfm}"); + + Console.WriteLine($"Original: {tfm}, Fx '{fx}' Standard: '{normalizedTfm}'"); + + // Test that our normalization handles the net100 -> net10.0 case + Assert.NotEqual("net100", normalizedTfm); + } } } diff --git a/bld.Tests/RecursiveDependencyResolverTests.cs b/bld.Tests/RecursiveDependencyResolverTests.cs new file mode 100644 index 0000000..e19e111 --- /dev/null +++ b/bld.Tests/RecursiveDependencyResolverTests.cs @@ -0,0 +1,96 @@ +using bld.Infrastructure; +using bld.Models; +using bld.Services.NuGet; +using System.Collections.Concurrent; +using Spectre.Console; + +namespace bld.Tests; + +public class RecursiveDependencyResolverTests { + + [Fact] + public async Task ResolveTransitiveDependencies_WithSimplePackage_ReturnsGraph() { + // Arrange + var options = new NugetMetadataOptions(); + using var httpClient = NugetMetadataService.CreateHttpClient(options); + var resolver = new RecursiveDependencyResolver(httpClient, options, null); // Use null for logger in tests + + var resolutionOptions = new DependencyResolutionOptions { + MaxDepth = 3, + AllowPrerelease = false, + TargetFrameworks = [] //new[] { "net8.0" } + }; + + // Act + var result = await resolver.ResolveTransitiveDependenciesAsync( + new[] { "Newtonsoft.Json" }, + resolutionOptions); + + // Assert + Assert.NotNull(result); + Assert.NotEmpty(result.RootPackages); + Assert.NotEmpty(result.AllPackages); + + var rootPackage = result.RootPackages.First(); + Assert.Equal("Newtonsoft.Json", rootPackage.PackageId); + Assert.True(rootPackage.Depth == 0); + + // Should have at least the root package in the flat list + Assert.Contains(result.AllPackages, p => p.PackageId == "Newtonsoft.Json" && p.IsRootPackage); + } + + [Fact] + public async Task ResolveTransitiveDependencies_WithMaxDepthLimit_RespectsLimit() { + // Arrange + var options = new NugetMetadataOptions(); + using var httpClient = NugetMetadataService.CreateHttpClient(options); + var resolver = new RecursiveDependencyResolver(httpClient, options, null); + + var resolutionOptions = new DependencyResolutionOptions { + MaxDepth = 1, // Very shallow to test limit + AllowPrerelease = false, + TargetFrameworks = [] //new[] { "net8.0" } + }; + + // Act + var result = await resolver.ResolveTransitiveDependenciesAsync( + new[] { "Microsoft.Extensions.Logging" }, + resolutionOptions); + + // Assert + Assert.NotNull(result); + + // All packages should have depth <= MaxDepth + Assert.All(result.AllPackages, p => Assert.True(p.Depth <= resolutionOptions.MaxDepth)); + } + + [Fact] + public async Task ResolveTransitiveDependencies_WithMultipleRootPackages_ReturnsAllRoots() { + // Arrange + var options = new NugetMetadataOptions(); + using var httpClient = NugetMetadataService.CreateHttpClient(options); + var resolver = new RecursiveDependencyResolver(httpClient, options, null); + + var resolutionOptions = new DependencyResolutionOptions { + MaxDepth = 2, + AllowPrerelease = false, + TargetFrameworks = [] //new[] { "net8.0" } + }; + + var rootPackages = new[] { "Newtonsoft.Json", "System.Text.Json" }; + + // Act + var result = await resolver.ResolveTransitiveDependenciesAsync( + rootPackages, + resolutionOptions); + + // Assert + Assert.NotNull(result); + Assert.Equal(2, result.RootPackages.Count); + + foreach (var expectedPackage in rootPackages) { + Assert.Contains(result.RootPackages, p => p.PackageId == expectedPackage); + Assert.Contains(result.AllPackages, p => p.PackageId == expectedPackage && p.IsRootPackage); + } + } +} \ No newline at end of file diff --git a/bld.Tests/ReverseDependencyGraphServiceTests.cs b/bld.Tests/ReverseDependencyGraphServiceTests.cs new file mode 100644 index 0000000..ad435cc --- /dev/null +++ b/bld.Tests/ReverseDependencyGraphServiceTests.cs @@ -0,0 +1,258 @@ +using bld.Infrastructure; +using bld.Models; +using bld.Services.NuGet; + +namespace bld.Tests; + +public sealed class ReverseDependencyGraphServiceTests { + + [Fact] + public void BuildReverseDependencyGraph_WithEmptyGraph_ReturnsEmptyAnalysis() { + // Arrange + var service = new ReverseDependencyGraphService(null); // Use null console for tests + var forwardGraph = new PackageDependencyGraph { + RootPackages = [], + AllPackages = [] + }; + + // Act + var result = service.BuildReverseDependencyGraph(forwardGraph, excludeFrameworkPackages: false); + + // Assert + Assert.Equal(0, result.TotalPackages); + Assert.Equal(0, result.ExplicitPackages); + Assert.Equal(0, result.TransitivePackages); + Assert.True(result.ReverseNodes.Count == 0); + } + + [Fact] + public void BuildReverseDependencyGraph_WithSingleRootPackage_CorrectlyIdentifiesExplicitPackage() { + // Arrange + var service = new ReverseDependencyGraphService(null); // Use null console for tests + var rootNode = new DependencyGraphNode { + PackageId = "RootPackage", + Version = "1.0.0", + TargetFramework = "net8.0", + Depth = 0, + Dependencies = [] + }; + + var forwardGraph = new PackageDependencyGraph { + RootPackages = [rootNode], + AllPackages = [new PackageReference { + PackageId = "RootPackage", + Version = "1.0.0", + TargetFramework = "net8.0", + IsRootPackage = true, + Depth = 0 + }] + }; + + // Act + var result = service.BuildReverseDependencyGraph(forwardGraph, excludeFrameworkPackages: false); + + // Assert + Assert.Equal(1, result.TotalPackages); + Assert.Equal(1, result.ExplicitPackages); + Assert.Equal(0, result.TransitivePackages); + + var reverseNode = result.ReverseNodes.First(); + Assert.Equal("RootPackage", reverseNode.PackageId); + Assert.True(reverseNode.IsExplicit); + Assert.Equal(0, reverseNode.DependentPackages.Count); + } + + [Fact] + public void BuildReverseDependencyGraph_WithDependencies_CorrectlyBuildsDependentsList() { + // Arrange + var service = new ReverseDependencyGraphService(null); // Use null console for tests + var childNode = new DependencyGraphNode { + PackageId = "ChildPackage", + Version = "2.0.0", + TargetFramework = "net8.0", + Depth = 1, + Dependencies = [] + }; + + var rootNode = new DependencyGraphNode { + PackageId = "RootPackage", + Version = "1.0.0", + TargetFramework = "net8.0", + Depth = 0, + Dependencies = [childNode] + }; + + var forwardGraph = new PackageDependencyGraph { + RootPackages = [rootNode], + AllPackages = [ + new PackageReference { + PackageId = "RootPackage", + Version = "1.0.0", + TargetFramework = "net8.0", + IsRootPackage = true, + Depth = 0 + }, + new PackageReference { + PackageId = "ChildPackage", + Version = "2.0.0", + TargetFramework = "net8.0", + IsRootPackage = false, + Depth = 1 + } + ] + }; + + // Act + var result = service.BuildReverseDependencyGraph(forwardGraph, excludeFrameworkPackages: false); + + // Assert + Assert.Equal(2, result.TotalPackages); + Assert.Equal(1, result.ExplicitPackages); + Assert.Equal(1, result.TransitivePackages); + + var childReverseNode = result.ReverseNodes.First(n => n.PackageId == "ChildPackage"); + Assert.False(childReverseNode.IsExplicit); + Assert.Equal(1, childReverseNode.DependentPackages.Count); + Assert.Equal("RootPackage", childReverseNode.DependentPackages[0].PackageId); + + var rootReverseNode = result.ReverseNodes.First(n => n.PackageId == "RootPackage"); + Assert.True(rootReverseNode.IsExplicit); + Assert.Equal(0, rootReverseNode.DependentPackages.Count); + } + + [Fact] + public void BuildReverseDependencyGraph_WithFrameworkPackages_CanExcludeFrameworkPackages() { + // Arrange + var service = new ReverseDependencyGraphService(null); // Use null console for tests + var microsoftNode = new DependencyGraphNode { + PackageId = "Microsoft.Extensions.Logging", + Version = "6.0.0", + TargetFramework = "net8.0", + Depth = 1, + Dependencies = [] + }; + + var systemNode = new DependencyGraphNode { + PackageId = "System.Text.Json", + Version = "6.0.0", + TargetFramework = "net8.0", + Depth = 1, + Dependencies = [] + }; + + var rootNode = new DependencyGraphNode { + PackageId = "MyCustomPackage", + Version = "1.0.0", + TargetFramework = "net8.0", + Depth = 0, + Dependencies = [microsoftNode, systemNode] + }; + + var forwardGraph = new PackageDependencyGraph { + RootPackages = [rootNode], + AllPackages = [ + new PackageReference { + PackageId = "MyCustomPackage", + Version = "1.0.0", + TargetFramework = "net8.0", + IsRootPackage = true, + Depth = 0 + }, + new PackageReference { + PackageId = "Microsoft.Extensions.Logging", + Version = "6.0.0", + TargetFramework = "net8.0", + IsRootPackage = false, + Depth = 1 + }, + new PackageReference { + PackageId = "System.Text.Json", + Version = "6.0.0", + TargetFramework = "net8.0", + IsRootPackage = false, + Depth = 1 + } + ] + }; + + // Act + var resultWithFramework = service.BuildReverseDependencyGraph(forwardGraph, excludeFrameworkPackages: false); + var resultWithoutFramework = service.BuildReverseDependencyGraph(forwardGraph, excludeFrameworkPackages: true); + + // Assert + Assert.Equal(3, resultWithFramework.TotalPackages); + Assert.Equal(1, resultWithoutFramework.TotalPackages); // Only MyCustomPackage should remain + + var customPackageNode = resultWithoutFramework.ReverseNodes.First(); + Assert.Equal("MyCustomPackage", customPackageNode.PackageId); + Assert.True(customPackageNode.IsExplicit); + } + + [Fact] + public void BuildReverseDependencyGraph_CalculatesMostReferencedPackagesCorrectly() { + // Arrange + var service = new ReverseDependencyGraphService(null); // Use null console for tests + var sharedNode = new DependencyGraphNode { + PackageId = "SharedPackage", + Version = "1.0.0", + TargetFramework = "net8.0", + Depth = 1, + Dependencies = [] + }; + + var root1 = new DependencyGraphNode { + PackageId = "Root1", + Version = "1.0.0", + TargetFramework = "net8.0", + Depth = 0, + Dependencies = [sharedNode] + }; + + var root2 = new DependencyGraphNode { + PackageId = "Root2", + Version = "1.0.0", + TargetFramework = "net8.0", + Depth = 0, + Dependencies = [sharedNode] + }; + + var forwardGraph = new PackageDependencyGraph { + RootPackages = [root1, root2], + AllPackages = [ + new PackageReference { + PackageId = "Root1", + Version = "1.0.0", + TargetFramework = "net8.0", + IsRootPackage = true, + Depth = 0 + }, + new PackageReference { + PackageId = "Root2", + Version = "1.0.0", + TargetFramework = "net8.0", + IsRootPackage = true, + Depth = 0 + }, + new PackageReference { + PackageId = "SharedPackage", + Version = "1.0.0", + TargetFramework = "net8.0", + IsRootPackage = false, + Depth = 1 + } + ] + }; + + // Act + var result = service.BuildReverseDependencyGraph(forwardGraph, excludeFrameworkPackages: false); + + // Assert + Assert.Equal(3, result.TotalPackages); + Assert.Equal(2, result.ExplicitPackages); + Assert.Equal(1, result.TransitivePackages); + + var mostReferenced = result.MostReferencedPackages.First(); + Assert.Equal("SharedPackage", mostReferenced.PackageId); + Assert.Equal(2, mostReferenced.ReferenceCount); + } +} \ No newline at end of file diff --git a/bld.sln b/bld.sln deleted file mode 100644 index e87f61a..0000000 --- a/bld.sln +++ /dev/null @@ -1,24 +0,0 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31903.59 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "bld", "bld\bld.csproj", "{12345678-1234-1234-1234-123456789012}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "bld.Tests", "bld.Tests\bld.Tests.csproj", "{85adeccc-511d-43d8-a5c0-6d41bdcc5173}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {12345678-1234-1234-1234-123456789012}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {12345678-1234-1234-1234-123456789012}.Debug|Any CPU.Build.0 = Debug|Any CPU - {12345678-1234-1234-1234-123456789012}.Release|Any CPU.ActiveCfg = Release|Any CPU - {12345678-1234-1234-1234-123456789012}.Release|Any CPU.Build.0 = Release|Any CPU - {85adeccc-511d-43d8-a5c0-6d41bdcc5173}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {85adeccc-511d-43d8-a5c0-6d41bdcc5173}.Debug|Any CPU.Build.0 = Debug|Any CPU - {85adeccc-511d-43d8-a5c0-6d41bdcc5173}.Release|Any CPU.ActiveCfg = Release|Any CPU - {85adeccc-511d-43d8-a5c0-6d41bdcc5173}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection -EndGlobal \ No newline at end of file diff --git a/bld/Commands/DepsGraphCommand.cs b/bld/Commands/DepsGraphCommand.cs new file mode 100644 index 0000000..8808e25 --- /dev/null +++ b/bld/Commands/DepsGraphCommand.cs @@ -0,0 +1,92 @@ +using bld.Infrastructure; +using bld.Models; +using bld.Services; +using System.CommandLine; + +namespace bld.Commands; + +internal sealed class DepsGraphCommand : BaseCommand { + + private readonly Option _applyOption = new Option("--apply") { + Description = "Apply package updates instead of just checking.", + DefaultValueFactory = _ => false + }; + + private readonly Option _skipTfmCheckOption = new Option("--skip-tfm-check") { + Description = "Skip target framework compatibility checking when suggesting package updates.", + DefaultValueFactory = _ => false + }; + + private readonly Option _prereleaseOption = new Option("--prerelease", "--pre") { + Description = "Include prerelease versions of NuGet packages.", + DefaultValueFactory = _ => false + }; + + private readonly Option _reverseOption = new Option("--reverse") { + Description = "Display reverse dependency graph showing which packages depend on each package.", + DefaultValueFactory = _ => false + }; + + private readonly Option _includeFrameworkOption = new Option("--include-framework") { + Description = "Include framework packages (Microsoft.*/System.*/NETStandard.*) in reverse dependency analysis (excluded by default).", + DefaultValueFactory = _ => false + }; + + private readonly Option _maxDepthOption = new Option("--max-depth") { + Description = "Maximum depth to traverse in the dependency tree (default: 8).", + DefaultValueFactory = _ => 8 + }; + + public DepsGraphCommand(IConsoleOutput console) : base("deps", "Check for outdated NuGet packages and optionally update them to latest versions.", console) { + Add(_rootOption); + Add(_depthOption); + Add(_maxDepthOption); + Add(_applyOption); + Add(_skipTfmCheckOption); + Add(_prereleaseOption); + Add(_reverseOption); + Add(_includeFrameworkOption); + Add(_logLevelOption); + Add(_vsToolsPath); + Add(_noResolveVsToolsPath); + Add(_rootArgument); + } + + protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) { + var options = new CleaningOptions { + LogLevel = parseResult.GetValue(_logLevelOption), + Depth = parseResult.GetValue(_depthOption), + VSToolsPath = parseResult.GetValue(_vsToolsPath), + NoResolveVSToolsPath = parseResult.GetValue(_noResolveVsToolsPath), + }; + + if (!options.NoResolveVSToolsPath && string.IsNullOrEmpty(options.VSToolsPath)) { + options.VSToolsPath = TryResolveVSToolsPath(out var vsRoot); + options.VSRootPath = vsRoot; + } + + base.Console = new SpectreConsoleOutput(options.LogLevel); + + var rootValue = parseResult.GetValue(_rootOption) ?? parseResult.GetValue(_rootArgument); + if (string.IsNullOrEmpty(rootValue)) { + rootValue = Directory.GetCurrentDirectory(); + } + + var applyUpdates = parseResult.GetValue(_applyOption); + var skipTfmCheck = parseResult.GetValue(_skipTfmCheckOption); + var includePrerelease = parseResult.GetValue(_prereleaseOption); + var showReverse = parseResult.GetValue(_reverseOption); + var includeFramework = parseResult.GetValue(_includeFrameworkOption); + var maxDepth = parseResult.GetValue(_maxDepthOption); + + var service = new DepsGraphService(Console, options); + + if (showReverse) { + // For reverse dependencies, we exclude framework packages by default (unless --include-framework is specified) + var excludeFramework = !includeFramework; + return await service.BuildReverseDependencyGraphAsync(rootValue, includePrerelease, excludeFramework, maxDepth, cancellationToken: cancellationToken); + } else { + return await service.BuildDependencyGraphAsync(rootValue, includePrerelease, maxDepth, cancellationToken: cancellationToken); + } + } +} diff --git a/bld/Commands/RootCommand.cs b/bld/Commands/RootCommand.cs index bac256b..f3c01dd 100644 --- a/bld/Commands/RootCommand.cs +++ b/bld/Commands/RootCommand.cs @@ -15,6 +15,7 @@ public RootCommand() : base("bld") { Add(new ContainerizeCommand(console)); Add(new CpmCommand(console)); Add(new OutdatedCommand(console)); + Add(new DepsGraphCommand(console)); Add(new TfmCommand(console)); } } diff --git a/bld/Models/DependencyGraphModels.cs b/bld/Models/DependencyGraphModels.cs new file mode 100644 index 0000000..465a16e --- /dev/null +++ b/bld/Models/DependencyGraphModels.cs @@ -0,0 +1,194 @@ +using NuGet.Versioning; +using bld.Services.NuGet; +using NuGet.Frameworks; + +namespace bld.Models; + +/// +/// Represents a node in the dependency graph containing package information and its dependencies +/// +internal record DependencyGraphNode { + public required string PackageId { get; init; } + public required string Version { get; init; } + public required string TargetFramework { get; init; } + public bool IsPrerelease { get; init; } + public DateTime RetrievedAt { get; init; } = DateTime.UtcNow; + + /// + /// Direct dependencies of this package + /// + public IReadOnlyList Dependencies { get; init; } = []; + + /// + /// The dependency group information used to resolve this node + /// + public DependencyGroup? DependencyGroup { get; init; } + + /// + /// Version range constraint from parent (if this node is a dependency) + /// + public string? VersionRange { get; init; } + + /// + /// Depth in the dependency tree (0 = root package) + /// + public int Depth { get; init; } +} + +/// +/// Represents the complete dependency graph with both tree structure and flat list +/// +internal record PackageDependencyGraph { + /// + /// Root packages (packages directly referenced by projects) + /// + public IReadOnlyList RootPackages { get; init; } = []; + + /// + /// Flat list of all packages found in the dependency tree (including roots) + /// + public IReadOnlyList AllPackages { get; init; } = []; + + /// + /// Packages that were requested but could not be resolved + /// + public IReadOnlyList UnresolvedPackages { get; init; } = []; + + /// + /// Total number of unique packages resolved + /// + public int TotalPackageCount => AllPackages.Count; + + /// + /// Maximum depth of the dependency tree + /// + public int MaxDepth { get; init; } +} + +/// +/// Represents a package reference with metadata for the flat list +/// +internal record PackageReference { + public required string PackageId { get; init; } + public required string Version { get; init; } + public required string TargetFramework { get; init; } + public bool IsPrerelease { get; init; } + public bool IsRootPackage { get; init; } + public int Depth { get; init; } + public string? VersionRange { get; init; } + public DateTime RetrievedAt { get; init; } = DateTime.UtcNow; +} + +/// +/// Represents a package that could not be resolved +/// +internal record UnresolvedPackage { + public required string PackageId { get; init; } + public string? VersionRange { get; init; } + public required NuGetFramework TargetFramework { get; init; } + public required string Reason { get; init; } + public int Depth { get; init; } +} + +/// +/// Options for dependency graph resolution +/// +internal record DependencyResolutionOptions { + /// + /// Maximum depth to traverse in the dependency tree (default: 10) + /// + public int MaxDepth { get; init; } = 10; + + /// + /// Whether to include prerelease packages in resolution + /// + public bool AllowPrerelease { get; init; } + + /// + /// Cache expiration time for package lookups + /// + public TimeSpan CacheExpiration { get; init; } = TimeSpan.FromMinutes(30); + + /// + /// Whether to stop resolution when a cycle is detected + /// + public bool StopOnCycles { get; init; } = true; + + /// + /// Target frameworks to resolve dependencies for + /// + public required IReadOnlyList TargetFrameworks { get; init; } +} + +/// +/// Represents vulnerability information for a NuGet package +/// +internal record PackageVulnerability { + public required string PackageId { get; init; } + public required string AffectedVersionRange { get; init; } + public required string AdvisoryUrl { get; init; } + public required string Severity { get; init; } + public required string Title { get; init; } + public string? Description { get; init; } + public DateTime PublishedDate { get; init; } + public string? CvssScore { get; init; } +} + +/// +/// Enhanced dependency analysis with vulnerability and conflict detection +/// +internal record EnhancedDependencyAnalysis { + public int TotalPackages { get; init; } + public int ExplicitPackages { get; init; } + public int TransitivePackages { get; init; } + public int MaxDepth { get; init; } + public int UnresolvedPackages { get; init; } + public int MicrosoftPackages { get; init; } + public int ThirdPartyPackages { get; init; } + public int VulnerablePackages { get; init; } + + public IReadOnlyList MostCommonDependencies { get; init; } = []; + public IReadOnlyDictionary PackagesByDepth { get; init; } = new Dictionary(); + public IReadOnlyList VersionConflicts { get; init; } = []; + public IReadOnlyList VersionIncompatibilities { get; init; } = []; + public IReadOnlyList Vulnerabilities { get; init; } = []; +} + +/// +/// Represents a version incompatibility where package versions may not work together +/// +internal record VersionIncompatibility { + public required string PackageId { get; init; } + public required IReadOnlyList IncompatibleVersions { get; init; } + public required string Reason { get; init; } +} + +/// +/// Enhanced package reference with vulnerability and conflict information +/// +internal record EnhancedPackageReference : PackageReference { + /// + /// Whether this package is explicitly referenced (vs. transitive) + /// + public bool IsExplicit { get; init; } + + /// + /// Vulnerability information for this package + /// + public IReadOnlyList Vulnerabilities { get; init; } = []; + + /// + /// Version conflicts this package participates in + /// + public IReadOnlyList ConflictingVersions { get; init; } = []; + + /// + /// Whether this package has any security vulnerabilities + /// + public bool HasVulnerabilities => Vulnerabilities.Any(); + + /// + /// Whether this package has version conflicts + /// + public bool HasVersionConflicts => ConflictingVersions.Any(); +} \ No newline at end of file diff --git a/bld/Services/DependencyTreeVisualizer.cs b/bld/Services/DependencyTreeVisualizer.cs new file mode 100644 index 0000000..adb312c --- /dev/null +++ b/bld/Services/DependencyTreeVisualizer.cs @@ -0,0 +1,330 @@ +using bld.Infrastructure; +using bld.Models; +using bld.Services.NuGet; +using Spectre.Console; +using NuGet.Versioning; + +namespace bld.Services; + +/// +/// Service for creating enhanced tree visualizations of dependency graphs using Spectre.Console +/// +internal class DependencyTreeVisualizer { + private readonly IConsoleOutput _console; + private readonly VulnerabilityService _vulnerabilityService; + + public DependencyTreeVisualizer(IConsoleOutput console, VulnerabilityService vulnerabilityService) { + _console = console; + _vulnerabilityService = vulnerabilityService; + } + + /// + /// Creates and displays an enhanced dependency tree visualization + /// + public async Task DisplayDependencyTreeAsync( + PackageDependencyGraph graph, + EnhancedDependencyAnalysis analysis, + bool showVulnerabilities = true, + CancellationToken cancellationToken = default) { + + _console.WriteRule("[bold blue]Dependency Tree Structure[/]"); + + // Get vulnerability data if requested + Dictionary>? vulnerabilities = null; + if (showVulnerabilities) { + var allPackageIds = graph.AllPackages.Select(p => p.PackageId).Distinct(); + vulnerabilities = await _vulnerabilityService.GetVulnerabilitiesAsync(allPackageIds, cancellationToken); + } + + // Create enhanced package references + var enhancedPackages = CreateEnhancedPackageReferences(graph, analysis, vulnerabilities); + + // Display summary first + DisplaySummaryPanel(analysis); + + // Display tree for each root package + foreach (var rootPackage in graph.RootPackages) { + var tree = CreatePackageTree(rootPackage, enhancedPackages, vulnerabilities); + _console.WriteTable(CreateTreeTable(tree)); + AnsiConsole.WriteLine(); + } + + // Display conflicts and vulnerabilities summary + if (analysis.VersionConflicts.Any() || analysis.VersionIncompatibilities.Any()) { + DisplayConflictsPanel(analysis); + } + + if (showVulnerabilities && vulnerabilities?.Values.SelectMany(v => v).Any() == true) { + DisplayVulnerabilitiesPanel(vulnerabilities); + } + } + + private Dictionary CreateEnhancedPackageReferences( + PackageDependencyGraph graph, + EnhancedDependencyAnalysis analysis, + Dictionary>? vulnerabilities) { + + var result = new Dictionary(); + + // Create lookup for conflicts + var conflictLookup = analysis.VersionConflicts + .ToDictionary(c => c.PackageId, c => c.Versions.ToList(), StringComparer.OrdinalIgnoreCase); + + foreach (var package in graph.AllPackages) { + var packageVulns = vulnerabilities?.GetValueOrDefault(package.PackageId, []) ?? []; + var conflictingVersions = conflictLookup.GetValueOrDefault(package.PackageId, []); + + var enhanced = new EnhancedPackageReference { + PackageId = package.PackageId, + Version = package.Version, + TargetFramework = package.TargetFramework, + IsPrerelease = package.IsPrerelease, + IsRootPackage = package.IsRootPackage, + IsExplicit = package.IsRootPackage, // Root packages are explicit + Depth = package.Depth, + VersionRange = package.VersionRange, + RetrievedAt = package.RetrievedAt, + Vulnerabilities = packageVulns, + ConflictingVersions = conflictingVersions + }; + + result[GetPackageKey(package)] = enhanced; + } + + return result; + } + + private string GetPackageKey(PackageReference package) { + return $"{package.PackageId}:{package.Version}:{package.TargetFramework}"; + } + + private Tree CreatePackageTree( + DependencyGraphNode rootPackage, + Dictionary enhancedPackages, + Dictionary>? vulnerabilities) { + + var rootKey = $"{rootPackage.PackageId}:{rootPackage.Version}:{rootPackage.TargetFramework}"; + var rootEnhanced = enhancedPackages.GetValueOrDefault(rootKey); + + var tree = new Tree(CreatePackageNodeText(rootPackage, rootEnhanced, true)); + + AddChildrenToTree(tree, rootPackage, enhancedPackages, vulnerabilities); + + return tree; + } + + private void AddChildrenToTree( + IHasTreeNodes parent, + DependencyGraphNode node, + Dictionary enhancedPackages, + Dictionary>? vulnerabilities) { + + foreach (var child in node.Dependencies.OrderBy(d => d.PackageId)) { + var childKey = $"{child.PackageId}:{child.Version}:{child.TargetFramework}"; + var childEnhanced = enhancedPackages.GetValueOrDefault(childKey); + + var childNode = parent.AddNode(CreatePackageNodeText(child, childEnhanced, false)); + + // Recursively add children (with depth limit to prevent cycles) + if (child.Depth < 10 && child.Dependencies.Any()) { + AddChildrenToTree(childNode, child, enhancedPackages, vulnerabilities); + } + } + } + + private string CreatePackageNodeText( + DependencyGraphNode node, + EnhancedPackageReference? enhanced, + bool isRoot) { + + var text = $"[bold]{Markup.Escape(node.PackageId)}[/] [dim]v{Markup.Escape(node.Version)}[/]"; + + // Add explicit/transitive marker + if (isRoot) { + text = $"[green]๐Ÿ“ฆ {text} (explicit)[/]"; + } else { + text = $"[yellow]๐Ÿ“„ {text} (transitive)[/]"; + } + + // Add version range if available + if (!string.IsNullOrEmpty(node.VersionRange)) { + text += $" [dim]({Markup.Escape(node.VersionRange)})[/]"; + } + + // Add framework + text += $" [cyan]{Markup.Escape(node.TargetFramework)}[/]"; + + // Add warnings/issues + var issues = new List(); + + if (enhanced?.HasVulnerabilities == true) { + var highSeverity = enhanced.Vulnerabilities.Any(v => + v.Severity.Equals("High", StringComparison.OrdinalIgnoreCase) || + v.Severity.Equals("Critical", StringComparison.OrdinalIgnoreCase)); + + if (highSeverity) { + issues.Add("[red]๐Ÿšจ HIGH VULNERABILITY[/]"); + } else { + issues.Add("[yellow]โš ๏ธ vulnerability[/]"); + } + } + + if (enhanced?.HasVersionConflicts == true) { + issues.Add("[orange3]โšก version conflict[/]"); + } + + if (node.IsPrerelease) { + issues.Add("[purple]๐Ÿงช prerelease[/]"); + } + + if (issues.Any()) { + text += $" {string.Join(" ", issues)}"; + } + + return text; + } + + private Table CreateTreeTable(Tree tree) { + var table = new Table() + .Border(TableBorder.None) + .AddColumn(new TableColumn("Dependency Tree").NoWrap()); + + table.AddRow(tree); + return table; + } + + private void DisplaySummaryPanel(EnhancedDependencyAnalysis analysis) { + var summaryTable = new Table() + .Border(TableBorder.Rounded) + .Title("[bold blue]Dependency Summary[/]"); + + summaryTable.AddColumn(new TableColumn("Metric").LeftAligned()); + summaryTable.AddColumn(new TableColumn("Count").RightAligned()); + + summaryTable.AddRow("๐Ÿ“ฆ Explicit Packages", analysis.ExplicitPackages.ToString()); + summaryTable.AddRow("๐Ÿ“„ Transitive Packages", analysis.TransitivePackages.ToString()); + summaryTable.AddRow("๐Ÿ“Š Total Packages", analysis.TotalPackages.ToString()); + summaryTable.AddRow("๐Ÿ“ Maximum Depth", analysis.MaxDepth.ToString()); + summaryTable.AddRow("๐Ÿข Microsoft Packages", analysis.MicrosoftPackages.ToString()); + summaryTable.AddRow("๐ŸŒ Third-party Packages", analysis.ThirdPartyPackages.ToString()); + + if (analysis.VulnerablePackages > 0) { + summaryTable.AddRow("[red]๐Ÿšจ Vulnerable Packages[/]", $"[red]{analysis.VulnerablePackages}[/]"); + } + + if (analysis.VersionConflicts.Any()) { + summaryTable.AddRow("[orange3]โšก Version Conflicts[/]", $"[orange3]{analysis.VersionConflicts.Count}[/]"); + } + + if (analysis.UnresolvedPackages > 0) { + summaryTable.AddRow("[yellow]โŒ Unresolved[/]", $"[yellow]{analysis.UnresolvedPackages}[/]"); + } + + _console.WriteTable(summaryTable); + AnsiConsole.WriteLine(); + } + + private void DisplayConflictsPanel(EnhancedDependencyAnalysis analysis) { + _console.WriteRule("[bold orange3]Version Conflicts & Incompatibilities[/]"); + + if (analysis.VersionConflicts.Any()) { + var conflictsTable = new Table() + .Border(TableBorder.Simple) + .Title("[orange3]Version Conflicts[/]"); + + conflictsTable.AddColumn("Package"); + conflictsTable.AddColumn("Conflicting Versions"); + conflictsTable.AddColumn("Impact"); + + foreach (var conflict in analysis.VersionConflicts) { + var impact = AssessConflictImpact(conflict.Versions); + conflictsTable.AddRow( + Markup.Escape(conflict.PackageId), + string.Join(", ", conflict.Versions.Select(v => Markup.Escape(v))), + impact + ); + } + + _console.WriteTable(conflictsTable); + AnsiConsole.WriteLine(); + } + + if (analysis.VersionIncompatibilities.Any()) { + var incompatTable = new Table() + .Border(TableBorder.Simple) + .Title("[red]Version Incompatibilities[/]"); + + incompatTable.AddColumn("Package"); + incompatTable.AddColumn("Incompatible Versions"); + incompatTable.AddColumn("Reason"); + + foreach (var incompatibility in analysis.VersionIncompatibilities) { + incompatTable.AddRow( + Markup.Escape(incompatibility.PackageId), + string.Join(", ", incompatibility.IncompatibleVersions.Select(v => Markup.Escape(v))), + Markup.Escape(incompatibility.Reason) + ); + } + + _console.WriteTable(incompatTable); + } + } + + private string AssessConflictImpact(IReadOnlyList versions) { + if (versions.Count == 2 && + NuGetVersion.TryParse(versions[0], out var v1) && + NuGetVersion.TryParse(versions[1], out var v2)) { + + var majorDiff = Math.Abs(v1.Major - v2.Major); + var minorDiff = Math.Abs(v1.Minor - v2.Minor); + + if (majorDiff > 0) { + return "[red]โš ๏ธ Major version difference - likely breaking[/]"; + } else if (minorDiff > 0) { + return "[yellow]โš ๏ธ Minor version difference - may have issues[/]"; + } else { + return "[green]โœ… Patch difference - likely safe[/]"; + } + } + + return "[yellow]โš ๏ธ Multiple versions - requires review[/]"; + } + + private void DisplayVulnerabilitiesPanel(Dictionary> vulnerabilities) { + _console.WriteRule("[bold red]Security Vulnerabilities[/]"); + + var vulnTable = new Table() + .Border(TableBorder.Heavy) + .Title("[red]Vulnerable Packages[/]"); + + vulnTable.AddColumn("Package"); + vulnTable.AddColumn("Severity"); + vulnTable.AddColumn("Affected Versions"); + vulnTable.AddColumn("Title"); + vulnTable.AddColumn("CVSS"); + + foreach (var (packageId, packageVulns) in vulnerabilities.Where(kvp => kvp.Value.Any())) { + foreach (var vuln in packageVulns.OrderByDescending(v => v.Severity)) { + var severityColor = vuln.Severity.ToLowerInvariant() switch { + "critical" => "red", + "high" => "red", + "medium" => "yellow", + "low" => "green", + _ => "white" + }; + + vulnTable.AddRow( + Markup.Escape(packageId), + $"[{severityColor}]{Markup.Escape(vuln.Severity)}[/]", + Markup.Escape(vuln.AffectedVersionRange), + Markup.Escape(vuln.Title), + string.IsNullOrEmpty(vuln.CvssScore) ? "-" : Markup.Escape(vuln.CvssScore) + ); + } + } + + _console.WriteTable(vulnTable); + + AnsiConsole.MarkupLine("[dim]๐Ÿ’ก Tip: Use 'dotnet list package --vulnerable' for more vulnerability details[/]"); + } +} \ No newline at end of file diff --git a/bld/Services/DepsGraphService.cs b/bld/Services/DepsGraphService.cs new file mode 100644 index 0000000..3197a35 --- /dev/null +++ b/bld/Services/DepsGraphService.cs @@ -0,0 +1,180 @@ +using bld.Infrastructure; +using bld.Models; +using bld.Services.NuGet; +using Spectre.Console; +using System.Diagnostics; +using System.Runtime.CompilerServices; + +namespace bld.Services; + +internal sealed class DepsGraphService(IConsoleOutput _console, CleaningOptions _options) { + + /// + /// Builds and analyzes a comprehensive dependency graph from discovered package references + /// + /// Root path to scan for solutions/projects + /// Whether to include prerelease packages + /// Maximum depth to traverse dependencies + /// Whether to show detailed analysis + /// Optional path to export dependency graph data + /// Cancellation token + /// Exit code + [MethodImpl(MethodImplOptions.NoInlining)] + public async Task BuildDependencyGraphAsync( + string rootPath, + bool includePrerelease = false, + int maxDepth = 8, + bool showAnalysis = true, + string? exportPath = null, + CancellationToken cancellationToken = default) { + + _console.WriteRule("[bold blue]bld dependency-graph (BETA)[/]"); + _console.WriteInfo("Discovering packages and building dependency graph..."); + + var stopwatch = Stopwatch.StartNew(); + + var discoveryService = new PackageDiscoveryService(_console, _options); + var (allPackageReferences, projectCount, errorSink) = await discoveryService.DiscoverPackageReferencesAsync(rootPath, cancellationToken); + + if (allPackageReferences.Count == 0) { + _console.WriteInfo("No package references found."); + return 0; + } + + _console.WriteInfo($"Found {allPackageReferences.Count} unique packages across {projectCount} projects"); + + // Now build the dependency graph using the new functionality + try { + var dependencyGraph = await allPackageReferences.BuildAndShowDependencyGraphAsync( + _console, + includePrerelease, + maxDepth, + showAnalysis, + true, // showVulnerabilities + cancellationToken); + + // Export if requested + if (!string.IsNullOrEmpty(exportPath)) { + var format = Path.GetExtension(exportPath).TrimStart('.').ToLowerInvariant(); + if (string.IsNullOrEmpty(format)) format = "json"; + + await dependencyGraph.ExportDependencyGraphAsync(exportPath, format, _console); + } + + stopwatch.Stop(); + _console.WriteInfo($"Total elapsed time: {stopwatch.Elapsed}"); + errorSink.WriteTo(); + + return 0; + } + catch (Exception ex) { + _console.WriteException(ex); + return 1; + } + } + + /// + /// Builds and displays a reverse dependency graph from discovered package references + /// + /// Root path to scan for solutions/projects + /// Whether to include prerelease packages + /// Whether to exclude Microsoft/System/NETStandard packages + /// Maximum depth to traverse dependencies + /// Optional path to export reverse dependency graph data + /// Cancellation token + /// Exit code + [MethodImpl(MethodImplOptions.NoInlining)] + public async Task BuildReverseDependencyGraphAsync( + string rootPath, + bool includePrerelease = false, + bool excludeFrameworkPackages = false, + int maxDepth = 8, + string? exportPath = null, + CancellationToken cancellationToken = default) { + + _console.WriteRule("[bold blue]bld reverse-dependency-graph (BETA)[/]"); + _console.WriteInfo("Discovering packages and building reverse dependency graph..."); + + var stopwatch = Stopwatch.StartNew(); + + var discoveryService = new PackageDiscoveryService(_console, _options); + var (allPackageReferences, projectCount, errorSink) = await discoveryService.DiscoverPackageReferencesAsync(rootPath, cancellationToken); + + if (allPackageReferences.Count == 0) { + _console.WriteInfo("No package references found."); + return 0; + } + + _console.WriteInfo($"Found {allPackageReferences.Count} unique packages across {projectCount} projects"); + + // Now build the reverse dependency graph using the new functionality + try { + var reverseAnalysis = await allPackageReferences.BuildAndShowReverseDependencyGraphAsync( + _console, + includePrerelease, + maxDepth, + excludeFrameworkPackages, + cancellationToken); + + // Export if requested (would need to implement export for reverse analysis) + if (!string.IsNullOrEmpty(exportPath)) { + await ExportReverseAnalysisAsync(reverseAnalysis, exportPath, _console, cancellationToken); + } + + stopwatch.Stop(); + _console.WriteInfo($"Total elapsed time: {stopwatch.Elapsed}"); + errorSink.WriteTo(); + + return 0; + } + catch (Exception ex) { + _console.WriteException(ex); + return 1; + } + } + + /// + /// Exports reverse dependency analysis to various formats + /// + private static async Task ExportReverseAnalysisAsync( + ReverseDependencyAnalysis analysis, + string outputPath, + IConsoleOutput console, + CancellationToken cancellationToken = default) { + + var directory = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) { + Directory.CreateDirectory(directory); + } + + var format = Path.GetExtension(outputPath).TrimStart('.').ToLowerInvariant(); + if (string.IsNullOrEmpty(format)) format = "json"; + + switch (format) { + case "json": + var json = System.Text.Json.JsonSerializer.Serialize(analysis, new System.Text.Json.JsonSerializerOptions { + WriteIndented = true + }); + await File.WriteAllTextAsync(outputPath, json, cancellationToken); + break; + + case "csv": + var csv = new System.Text.StringBuilder(); + csv.AppendLine("PackageId,Version,TargetFramework,IsExplicit,IsFrameworkPackage,ReferenceCount,DependentPackages"); + + foreach (var node in analysis.ReverseNodes.OrderBy(n => n.PackageId)) { + var dependentPackageIds = string.Join("|", node.DependentPackages.Select(d => d.PackageId)); + csv.AppendLine($"{node.PackageId},{node.Version},{node.TargetFramework},{node.IsExplicit},{node.IsFrameworkPackage},{node.ReferenceCount},\"{dependentPackageIds}\""); + } + + await File.WriteAllTextAsync(outputPath, csv.ToString(), cancellationToken); + break; + + default: + throw new ArgumentException($"Unsupported export format: {format}"); + } + + console.WriteInfo($"Reverse dependency analysis exported to: {outputPath}"); + } + +} diff --git a/bld/Services/DepsGraphServiceExtensions.cs b/bld/Services/DepsGraphServiceExtensions.cs new file mode 100644 index 0000000..780c61e --- /dev/null +++ b/bld/Services/DepsGraphServiceExtensions.cs @@ -0,0 +1,266 @@ +using bld.Infrastructure; +using bld.Models; +using bld.Services.NuGet; +using Spectre.Console; + +namespace bld.Services; + +/// +/// Extensions for OutdatedService to provide dependency graph functionality +/// +internal static class DepsGraphServiceExtensions { + + /// + /// Builds and displays a comprehensive dependency graph from discovered packages + /// + /// Package references discovered by OutdatedService + /// Console output service + /// Whether to include prerelease packages + /// Maximum depth to traverse + /// Whether to show detailed analysis + /// Whether to check and display vulnerability information + /// Cancellation token + /// The built dependency graph + public static async Task BuildAndShowDependencyGraphAsync( + this Dictionary allPackageReferences, + IConsoleOutput console, + bool includePrerelease = false, + int maxDepth = 8, + bool showAnalysis = true, + bool showVulnerabilities = true, + CancellationToken cancellationToken = default) { + + ArgumentNullException.ThrowIfNull(allPackageReferences); + ArgumentNullException.ThrowIfNull(console); + + console.WriteRule("[bold blue]Dependency Graph Analysis[/]"); + + var graphService = new DependencyGraphService(console); + var dependencyGraph = await graphService.BuildDependencyGraphAsync( + allPackageReferences, + includePrerelease, + maxDepth, + cancellationToken); + + // Get vulnerability information if requested + Dictionary>? vulnerabilities = null; + if (showVulnerabilities) { + using var httpClient = new HttpClient(); + var vulnerabilityService = new VulnerabilityService(httpClient, console); + var packageIds = dependencyGraph.AllPackages.Select(p => p.PackageId).Distinct(); + vulnerabilities = await vulnerabilityService.GetVulnerabilitiesAsync(packageIds, cancellationToken); + } + + // Perform enhanced analysis + var enhancedAnalysis = await graphService.AnalyzeDependencyGraphEnhancedAsync( + dependencyGraph, + vulnerabilities, + cancellationToken); + + // Create and display enhanced tree visualization + using var httpClient2 = new HttpClient(); + var vulnerabilityService2 = new VulnerabilityService(httpClient2, console); + var treeVisualizer = new DependencyTreeVisualizer(console, vulnerabilityService2); + + await treeVisualizer.DisplayDependencyTreeAsync( + dependencyGraph, + enhancedAnalysis, + showVulnerabilities, + cancellationToken); + + // Show legacy summary if requested - disabled per user feedback for cleaner output + // if (showAnalysis) { + // DisplayLegacySummary(enhancedAnalysis, console); + // } + + return dependencyGraph; + } + + /// + /// Builds and displays a reverse dependency graph from discovered packages + /// + /// Package references discovered by OutdatedService + /// Console output service + /// Whether to include prerelease packages + /// Maximum depth to traverse + /// Whether to exclude Microsoft/System/NETStandard packages + /// Cancellation token + /// The reverse dependency analysis + public static async Task BuildAndShowReverseDependencyGraphAsync( + this Dictionary allPackageReferences, + IConsoleOutput console, + bool includePrerelease = false, + int maxDepth = 8, + bool excludeFrameworkPackages = false, + CancellationToken cancellationToken = default) { + + ArgumentNullException.ThrowIfNull(allPackageReferences); + ArgumentNullException.ThrowIfNull(console); + + // First build the forward dependency graph + var forwardGraph = await BuildAndShowDependencyGraphAsync( + allPackageReferences, + console, + includePrerelease, + maxDepth, + showAnalysis: false, // Don't show analysis for forward graph + showVulnerabilities: false, // Don't show vulnerabilities for forward graph + cancellationToken); + + // Build reverse dependency graph + var reverseService = new ReverseDependencyGraphService(console); + var reverseAnalysis = reverseService.BuildReverseDependencyGraph(forwardGraph, excludeFrameworkPackages); + + // Display reverse dependency visualization + var reverseVisualizer = new ReverseDependencyTreeVisualizer(console); + await reverseVisualizer.DisplayReverseDependencyAnalysisAsync( + reverseAnalysis, + excludeFrameworkPackages, + cancellationToken); + + return reverseAnalysis; + } + + /// + /// Displays a legacy summary for backward compatibility + /// + private static void DisplayLegacySummary(EnhancedDependencyAnalysis analysis, IConsoleOutput console) { + console.WriteRule("[bold green]Additional Analysis Details[/]"); + + // Depth distribution + if (analysis.PackagesByDepth.Any()) { + console.WriteInfo("\n[bold]Package Distribution by Depth:[/]"); + var depthTable = new Table().Border(TableBorder.Simple); + depthTable.AddColumn("Depth"); + depthTable.AddColumn("Package Count"); + depthTable.AddColumn("Percentage"); + + foreach (var (depth, count) in analysis.PackagesByDepth.OrderBy(kvp => kvp.Key)) { + var percentage = (count * 100.0 / analysis.TotalPackages).ToString("F1"); + depthTable.AddRow( + depth.ToString(), + count.ToString(), + $"{percentage}%" + ); + } + console.WriteTable(depthTable); + } + + // Most common dependencies + if (analysis.MostCommonDependencies.Any()) { + console.WriteInfo("\n[bold]Most Common Transitive Dependencies:[/]"); + var depTable = new Table().Border(TableBorder.Simple); + depTable.AddColumn("Package"); + depTable.AddColumn("Used By # Projects"); + depTable.AddColumn("Category"); + + foreach (var dep in analysis.MostCommonDependencies) { + var category = CategorizePackage(dep.PackageId); + depTable.AddRow( + Markup.Escape(dep.PackageId), + dep.Frequency.ToString(), + category + ); + } + console.WriteTable(depTable); + } + } + + private static string CategorizePackage(string packageId) { + return packageId.ToLowerInvariant() switch { + var p when p.StartsWith("microsoft.") => "[blue]Microsoft[/]", + var p when p.StartsWith("system.") => "[blue]System[/]", + var p when p.StartsWith("newtonsoft.") => "[green]JSON/Serialization[/]", + var p when p.Contains("logging") => "[cyan]Logging[/]", + var p when p.Contains("test") => "[yellow]Testing[/]", + var p when p.Contains("entity") => "[purple]Data/ORM[/]", + var p when p.Contains("http") => "[orange3]HTTP/Web[/]", + _ => "[dim]Third-party[/]" + }; + } + + /// + /// Exports the dependency graph to various formats + /// + /// The dependency graph to export + /// Output file path + /// Export format (json, csv, dot) + /// Console output service + public static async Task ExportDependencyGraphAsync( + this PackageDependencyGraph graph, + string outputPath, + string format = "json", + IConsoleOutput? console = null) { + + ArgumentNullException.ThrowIfNull(graph); + ArgumentException.ThrowIfNullOrWhiteSpace(outputPath); + + var directory = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) { + Directory.CreateDirectory(directory); + } + + switch (format.ToLowerInvariant()) { + case "json": + await ExportToJsonAsync(graph, outputPath); + break; + case "csv": + await ExportToCsvAsync(graph, outputPath); + break; + case "dot": + await ExportToDotAsync(graph, outputPath); + break; + default: + throw new ArgumentException($"Unsupported export format: {format}"); + } + + console?.WriteInfo($"Dependency graph exported to: {outputPath}"); + } + + private static async Task ExportToJsonAsync(PackageDependencyGraph graph, string outputPath) { + var json = System.Text.Json.JsonSerializer.Serialize(graph, new System.Text.Json.JsonSerializerOptions { + WriteIndented = true + }); + await File.WriteAllTextAsync(outputPath, json); + } + + private static async Task ExportToCsvAsync(PackageDependencyGraph graph, string outputPath) { + var csv = new System.Text.StringBuilder(); + csv.AppendLine("PackageId,Version,TargetFramework,IsRootPackage,Depth,IsPrerelease,VersionRange"); + + foreach (var package in graph.AllPackages.OrderBy(p => p.PackageId).ThenBy(p => p.Depth)) { + csv.AppendLine($"{package.PackageId},{package.Version},{package.TargetFramework},{package.IsRootPackage},{package.Depth},{package.IsPrerelease},\"{package.VersionRange}\""); + } + + await File.WriteAllTextAsync(outputPath, csv.ToString()); + } + + private static async Task ExportToDotAsync(PackageDependencyGraph graph, string outputPath) { + var dot = new System.Text.StringBuilder(); + dot.AppendLine("digraph DependencyGraph {"); + dot.AppendLine(" rankdir=TB;"); + dot.AppendLine(" node [shape=box];"); + + // Add nodes + foreach (var package in graph.AllPackages) { + var style = package.IsRootPackage ? "filled,bold" : "filled"; + var color = package.IsRootPackage ? "lightblue" : "lightgray"; + dot.AppendLine($" \"{package.PackageId}\" [style=\"{style}\", fillcolor=\"{color}\"];"); + } + + // Add edges (this is simplified - would need to reconstruct relationships) + foreach (var rootPackage in graph.RootPackages) { + AddDotEdges(dot, rootPackage); + } + + dot.AppendLine("}"); + await File.WriteAllTextAsync(outputPath, dot.ToString()); + } + + private static void AddDotEdges(System.Text.StringBuilder dot, DependencyGraphNode node) { + foreach (var dependency in node.Dependencies) { + dot.AppendLine($" \"{node.PackageId}\" -> \"{dependency.PackageId}\";"); + AddDotEdges(dot, dependency); + } + } +} \ No newline at end of file diff --git a/bld/Services/NuGet/DependencyGraphService.cs b/bld/Services/NuGet/DependencyGraphService.cs new file mode 100644 index 0000000..8f4606a --- /dev/null +++ b/bld/Services/NuGet/DependencyGraphService.cs @@ -0,0 +1,286 @@ +using bld.Infrastructure; +using bld.Models; +using NuGet.Frameworks; +using NuGet.Versioning; + +namespace bld.Services.NuGet; + +/// +/// Service for building comprehensive NuGet dependency graphs from project package references +/// +internal class DependencyGraphService { + private readonly IConsoleOutput _console; + private readonly NugetMetadataOptions _options; + + public DependencyGraphService(IConsoleOutput console, NugetMetadataOptions? options = null) { + _console = console ?? throw new ArgumentNullException(nameof(console)); + _options = options ?? new NugetMetadataOptions(); + } + + /// + /// Builds a complete dependency graph from package references discovered by OutdatedService + /// + /// Package references from OutdatedService.CheckOutdatedPackagesAsync + /// Whether to include prerelease packages + /// Maximum depth to traverse (default: 8) + /// Cancellation token + /// Complete dependency graph with both tree and flat representations + public async Task BuildDependencyGraphAsync( + Dictionary allPackageReferences, + bool includePrerelease = false, + int maxDepth = 8, + CancellationToken cancellationToken = default) { + + ArgumentNullException.ThrowIfNull(allPackageReferences); + + _console.WriteInfo($"Building dependency graph for {allPackageReferences.Count} packages..."); + + // Extract unique target frameworks from all packages + var targetFrameworks = allPackageReferences.Values + .SelectMany(container => container.Tfms) + .Distinct() + .ToList(); + + //if (!targetFrameworks.Any()) { + // targetFrameworks.Add("net8.0"); // Default fallback + //} + + _console.WriteDebug($"Target frameworks: {string.Join(", ", targetFrameworks)}"); + + var resolutionOptions = new DependencyResolutionOptions { + MaxDepth = maxDepth, + AllowPrerelease = includePrerelease, + TargetFrameworks = targetFrameworks.Select(tf => new NuGetFramework(tf)).ToList() + }; + + using var httpClient = NugetMetadataService.CreateHttpClient(_options); + var resolver = new RecursiveDependencyResolver(httpClient, _options, _console); + + // Get root package IDs + var rootPackageIds = allPackageReferences.Keys.ToList(); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var dependencyGraph = await resolver.ResolveTransitiveDependenciesAsync( + rootPackageIds, + resolutionOptions, + allPackageReferences, + cancellationToken); + + stopwatch.Stop(); + + _console.WriteInfo($"Dependency graph built in {stopwatch.Elapsed.TotalSeconds:F2} seconds"); + _console.WriteInfo($"Found {dependencyGraph.TotalPackageCount} total packages (max depth: {dependencyGraph.MaxDepth})"); + + if (dependencyGraph.UnresolvedPackages.Any()) { + _console.WriteWarning($"{dependencyGraph.UnresolvedPackages.Count} packages could not be resolved:"); + foreach (var unresolved in dependencyGraph.UnresolvedPackages.Take(10)) { + _console.WriteWarning($" - {unresolved.PackageId}: {unresolved.Reason}"); + } + if (dependencyGraph.UnresolvedPackages.Count > 10) { + _console.WriteWarning($" ... and {dependencyGraph.UnresolvedPackages.Count - 10} more"); + } + } + + return dependencyGraph; + } + + /// + /// Analyzes the dependency graph for interesting patterns and statistics + /// + public DependencyGraphAnalysis AnalyzeDependencyGraph(PackageDependencyGraph graph) { + ArgumentNullException.ThrowIfNull(graph); + + // Find most common dependencies (packages that appear in many dependency trees) + var packageFrequency = new Dictionary(); + foreach (var package in graph.AllPackages.Where(p => !p.IsRootPackage)) { + packageFrequency[package.PackageId] = packageFrequency.GetValueOrDefault(package.PackageId, 0) + 1; + } + + var mostCommonDependencies = packageFrequency + .OrderByDescending(kvp => kvp.Value) + .Take(10) + .Select(kvp => new DependencyFrequency { PackageId = kvp.Key, Frequency = kvp.Value }) + .ToList(); + + // Find packages by depth + var packagesByDepth = graph.AllPackages + .GroupBy(p => p.Depth) + .ToDictionary(g => g.Key, g => g.Count()); + + // Find Microsoft vs third-party packages + var microsoftPackages = graph.AllPackages.Where(p => + p.PackageId.StartsWith("Microsoft.", StringComparison.OrdinalIgnoreCase) || + p.PackageId.StartsWith("System.", StringComparison.OrdinalIgnoreCase)).ToList(); + + var microsoftCount = microsoftPackages.Count; + var thirdPartyCount = graph.TotalPackageCount - microsoftCount; + + // Find potential version conflicts (same package with different versions) + var versionConflicts = graph.AllPackages + .GroupBy(p => p.PackageId) + .Where(g => g.Select(p => p.Version).Distinct().Count() > 1) + .Select(g => new VersionConflict { + PackageId = g.Key, + Versions = g.Select(p => p.Version).Distinct().ToList() + }) + .ToList(); + + return new DependencyGraphAnalysis { + TotalPackages = graph.TotalPackageCount, + RootPackages = graph.RootPackages.Count, + MaxDepth = graph.MaxDepth, + UnresolvedPackages = graph.UnresolvedPackages.Count, + MicrosoftPackages = microsoftCount, + ThirdPartyPackages = thirdPartyCount, + MostCommonDependencies = mostCommonDependencies, + PackagesByDepth = packagesByDepth, + VersionConflicts = versionConflicts + }; + } + + /// + /// Performs enhanced analysis including vulnerability and compatibility checks + /// + public async Task AnalyzeDependencyGraphEnhancedAsync( + PackageDependencyGraph graph, + Dictionary>? vulnerabilities = null, + CancellationToken cancellationToken = default) { + + ArgumentNullException.ThrowIfNull(graph); + + var basicAnalysis = AnalyzeDependencyGraph(graph); + + // Count explicit vs transitive packages + var explicitPackages = graph.AllPackages.Count(p => p.IsRootPackage); + var transitivePackages = graph.TotalPackageCount - explicitPackages; + + // Find version incompatibilities (more sophisticated than conflicts) + var versionIncompatibilities = FindVersionIncompatibilities(graph); + + // Count vulnerable packages + var vulnerablePackages = 0; + var allVulns = new List(); + + if (vulnerabilities != null) { + foreach (var (packageId, packageVulns) in vulnerabilities) { + if (packageVulns.Any()) { + // Check if any package versions are actually vulnerable + var packageVersions = graph.AllPackages + .Where(p => p.PackageId.Equals(packageId, StringComparison.OrdinalIgnoreCase)) + .Select(p => p.Version) + .Distinct(); + + var isVulnerable = await IsAnyVersionVulnerableAsync(packageVersions, packageVulns); + if (isVulnerable) { + vulnerablePackages++; + allVulns.AddRange(packageVulns); + } + } + } + } + + return new EnhancedDependencyAnalysis { + TotalPackages = basicAnalysis.TotalPackages, + ExplicitPackages = explicitPackages, + TransitivePackages = transitivePackages, + MaxDepth = basicAnalysis.MaxDepth, + UnresolvedPackages = basicAnalysis.UnresolvedPackages, + MicrosoftPackages = basicAnalysis.MicrosoftPackages, + ThirdPartyPackages = basicAnalysis.ThirdPartyPackages, + VulnerablePackages = vulnerablePackages, + MostCommonDependencies = basicAnalysis.MostCommonDependencies, + PackagesByDepth = basicAnalysis.PackagesByDepth, + VersionConflicts = basicAnalysis.VersionConflicts, + VersionIncompatibilities = versionIncompatibilities, + Vulnerabilities = allVulns + }; + } + + private List FindVersionIncompatibilities(PackageDependencyGraph graph) { + var incompatibilities = new List(); + + // Group packages by ID to find those with multiple versions + var packageGroups = graph.AllPackages + .GroupBy(p => p.PackageId, StringComparer.OrdinalIgnoreCase) + .Where(g => g.Select(p => p.Version).Distinct().Count() > 1); + + foreach (var group in packageGroups) { + var versions = group.Select(p => p.Version).Distinct().ToList(); + + // Check for major version differences (likely incompatible) + if (versions.Count >= 2) { + var parsedVersions = versions + .Select(v => NuGetVersion.TryParse(v, out var parsed) ? parsed : null) + .Where(v => v != null) + .ToList(); + + if (parsedVersions.Count >= 2) { + var majorVersions = parsedVersions.Select(v => v!.Major).Distinct().ToList(); + + if (majorVersions.Count > 1) { + incompatibilities.Add(new VersionIncompatibility { + PackageId = group.Key, + IncompatibleVersions = versions, + Reason = $"Major version differences: {string.Join(", ", majorVersions)} may be incompatible" + }); + } + } + } + } + + return incompatibilities; + } + + private Task IsAnyVersionVulnerableAsync( + IEnumerable versions, + List vulnerabilities) { + + foreach (var version in versions) { + if (!NuGetVersion.TryParse(version, out var nugetVersion)) { + continue; + } + + foreach (var vuln in vulnerabilities) { + if (VersionRange.TryParse(vuln.AffectedVersionRange, out var range) && + range.Satisfies(nugetVersion)) { + return Task.FromResult(true); + } + } + } + + return Task.FromResult(false); + } +} + +/// +/// Analysis results for a dependency graph +/// +internal record DependencyGraphAnalysis { + public int TotalPackages { get; init; } + public int RootPackages { get; init; } + public int MaxDepth { get; init; } + public int UnresolvedPackages { get; init; } + public int MicrosoftPackages { get; init; } + public int ThirdPartyPackages { get; init; } + + public IReadOnlyList MostCommonDependencies { get; init; } = []; + public IReadOnlyDictionary PackagesByDepth { get; init; } = new Dictionary(); + public IReadOnlyList VersionConflicts { get; init; } = []; +} + +/// +/// Represents how frequently a dependency appears across different packages +/// +internal record DependencyFrequency { + public required string PackageId { get; init; } + public int Frequency { get; init; } +} + +/// +/// Represents a version conflict where the same package appears with different versions +/// +internal record VersionConflict { + public required string PackageId { get; init; } + public required IReadOnlyList Versions { get; init; } +} \ No newline at end of file diff --git a/bld/Services/NuGet/RecursiveDependencyResolver.cs b/bld/Services/NuGet/RecursiveDependencyResolver.cs new file mode 100644 index 0000000..8af9634 --- /dev/null +++ b/bld/Services/NuGet/RecursiveDependencyResolver.cs @@ -0,0 +1,298 @@ +using bld.Infrastructure; +using bld.Models; +using NuGet.Frameworks; +using NuGet.Versioning; +using System.Collections.Concurrent; + +namespace bld.Services.NuGet; + +/// +/// Resolves NuGet package dependencies recursively, building a complete dependency graph +/// +internal class RecursiveDependencyResolver { + private readonly HttpClient _httpClient; + private readonly NugetMetadataOptions _options; + private readonly IConsoleOutput? _logger; + + // Cache for package version results to avoid duplicate requests + private readonly ConcurrentDictionary _packageCache = new(); + + // Set to track packages currently being resolved to detect cycles + private readonly HashSet _resolvingPackages = new(); + + public RecursiveDependencyResolver(HttpClient httpClient, NugetMetadataOptions options, IConsoleOutput? logger = null) { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + _logger = logger; + } + + /// + /// Resolves all transitive dependencies for the given root packages + /// + public async Task ResolveTransitiveDependenciesAsync( + IEnumerable rootPackageIds, + DependencyResolutionOptions options, + Dictionary? existingPackageReferences = null, + CancellationToken cancellationToken = default) { + + ArgumentNullException.ThrowIfNull(rootPackageIds); + ArgumentNullException.ThrowIfNull(options); + + // Pre-populate cache with existing package references to avoid duplicate fetches + if (existingPackageReferences != null) { + await PrePopulateCacheAsync(existingPackageReferences, options, cancellationToken); + } + + var rootNodes = new List(); + var allPackages = new ConcurrentBag(); + var unresolvedPackages = new ConcurrentBag(); + var maxDepth = 0; + + // Process each root package + await Parallel.ForEachAsync(rootPackageIds, new ParallelOptions { + MaxDegreeOfParallelism = _options.MaxParallelRequests, + CancellationToken = cancellationToken + }, async (rootPackageId, ct) => { + try { + var rootNode = await ResolvePackageRecursivelyAsync( + rootPackageId, + versionRange: null, + options, + depth: 0, + ct); + + if (rootNode != null) { + lock (rootNodes) { + rootNodes.Add(rootNode); + } + + // Flatten the tree and collect all packages + var flatPackages = new List(); + CollectAllPackages(rootNode, flatPackages, true); + + foreach (var pkg in flatPackages) { + allPackages.Add(pkg); + if (pkg.Depth > maxDepth) { + maxDepth = pkg.Depth; + } + } + } else { + unresolvedPackages.Add(new UnresolvedPackage { + PackageId = rootPackageId, + TargetFramework = options.TargetFrameworks.FirstOrDefault() ?? NuGetFramework.AnyFramework, + Reason = "Failed to resolve root package", + Depth = 0 + }); + } + } + catch (Exception ex) { + _logger?.WriteError($"Failed to resolve dependencies for {rootPackageId}: {ex.Message}"); + unresolvedPackages.Add(new UnresolvedPackage { + PackageId = rootPackageId, + TargetFramework = options.TargetFrameworks.FirstOrDefault() ?? NuGetFramework.AnyFramework, + Reason = $"Exception: {ex.Message}", + Depth = 0 + }); + } + }); + + // Deduplicate packages by PackageId + Version + TargetFramework + var uniquePackages = allPackages + .GroupBy(p => new { p.PackageId, p.Version, p.TargetFramework }) + .Select(g => g.OrderBy(p => p.Depth).First()) // Keep the one with minimum depth + .OrderBy(p => p.PackageId) + .ThenBy(p => p.Depth) + .ToList(); + + return new PackageDependencyGraph { + RootPackages = rootNodes.OrderBy(n => n.PackageId).ToList(), + AllPackages = uniquePackages, + UnresolvedPackages = unresolvedPackages.OrderBy(u => u.PackageId).ToList(), + MaxDepth = maxDepth + }; + } + + /// + /// Pre-populate cache with existing package references from OutdatedService + /// + private async Task PrePopulateCacheAsync( + Dictionary existingPackageReferences, + DependencyResolutionOptions options, + CancellationToken cancellationToken) { + + _logger?.WriteDebug($"Pre-populating cache with {existingPackageReferences.Count} existing package references"); + + await Parallel.ForEachAsync(existingPackageReferences, new ParallelOptions { + MaxDegreeOfParallelism = _options.MaxParallelRequests, + CancellationToken = cancellationToken + }, async (kvp, ct) => { + var packageId = kvp.Key; + var packageContainer = kvp.Value; + + // Create cache key + var cacheKey = CreateCacheKey(packageId, options.TargetFrameworks); + + // If not already cached, fetch and cache it + if (!_packageCache.ContainsKey(cacheKey)) { + try { + var request = new PackageVersionRequest { + PackageId = packageId, + AllowPrerelease = options.AllowPrerelease, + CompatibleTargetFrameworks = options.TargetFrameworks.Select(tf => tf.GetShortFolderName()).ToList() + }; + + var result = await NugetMetadataService.GetLatestVersionWithFrameworkCheckAsync( + _httpClient, _options, _logger, request, ct); + + _packageCache.TryAdd(cacheKey, result); + _logger?.WriteDebug($"Cached package metadata for {packageId}"); + } + catch (Exception ex) { + _logger?.WriteError($"Failed to pre-cache package {packageId}: {ex.Message}"); + _packageCache.TryAdd(cacheKey, null); + } + } + }); + } + + /// + /// Recursively resolves a package and all its dependencies + /// + private async Task ResolvePackageRecursivelyAsync( + string packageId, + string? versionRange, + DependencyResolutionOptions options, + int depth, + CancellationToken cancellationToken) { + + // Check depth limit + if (depth > options.MaxDepth) { + _logger?.WriteWarning($"Maximum depth {options.MaxDepth} reached for package {packageId}"); + return null; + } + + // Check for cycles + var resolutionKey = $"{packageId}@{depth}"; + lock (_resolvingPackages) { + if (_resolvingPackages.Contains(packageId)) { + _logger?.WriteWarning($"Cycle detected for package {packageId} at depth {depth}"); + return options.StopOnCycles ? null : null; + } + _resolvingPackages.Add(packageId); + } + + try { + // Try to get from cache first + var cacheKey = CreateCacheKey(packageId, options.TargetFrameworks); + + if (!_packageCache.TryGetValue(cacheKey, out var packageResult)) { + var request = new PackageVersionRequest { + PackageId = packageId, + AllowPrerelease = options.AllowPrerelease, + CompatibleTargetFrameworks = options.TargetFrameworks.Select(tf => tf.GetShortFolderName()).ToList() + }; + + packageResult = await NugetMetadataService.GetLatestVersionWithFrameworkCheckAsync( + _httpClient, _options, _logger, request, cancellationToken); + + _packageCache.TryAdd(cacheKey, packageResult); + } + + if (packageResult == null) { + _logger?.WriteWarning($"Could not resolve package {packageId}"); + return null; + } + + // Get the best target framework and its version + var targetFramework = options.TargetFrameworks.FirstOrDefault() ?? NuGetFramework.AnyFramework; + if (!packageResult.TargetFrameworkVersions.TryGetValue(targetFramework, out var version)) { + // Try with the first available framework + var firstFramework = packageResult.TargetFrameworkVersions.FirstOrDefault(); + if (firstFramework.Key != null) { + targetFramework = firstFramework.Key; + version = firstFramework.Value; + } else { + _logger?.WriteWarning($"No compatible version found for {packageId}"); + return null; + } + } + + // Check version range compatibility if specified + if (!string.IsNullOrEmpty(versionRange)) { + var parsedVersionRange = VersionRange.Parse(versionRange); + var parsedVersion = NuGetVersion.Parse(version); + if (!parsedVersionRange.Satisfies(parsedVersion)) { + _logger?.WriteDebug($"Version {version} of {packageId} does not satisfy range {versionRange}"); + // Still continue, but log the issue + } + } + + // Get dependency group for this target framework + DependencyGroup? dependencyGroup = null; + packageResult.Dependencies?.TryGetValue(targetFramework, out dependencyGroup); + + var childDependencies = new List(); + + // Resolve child dependencies + if (dependencyGroup?.Dependencies != null && dependencyGroup.Dependencies.Any()) { + var childTasks = dependencyGroup.Dependencies.Select(async dep => { + return await ResolvePackageRecursivelyAsync( + dep.PackageId, + dep.Range, + options, + depth + 1, + cancellationToken); + }); + + var resolvedChildren = await Task.WhenAll(childTasks); + childDependencies.AddRange(resolvedChildren.Where(child => child != null)!); + } + + return new DependencyGraphNode { + PackageId = packageId, + Version = version, + TargetFramework = targetFramework.GetShortFolderName(), + IsPrerelease = packageResult.IsPrerelease, + Dependencies = childDependencies, + DependencyGroup = dependencyGroup, + VersionRange = versionRange, + Depth = depth, + RetrievedAt = packageResult.RetrievedAt + }; + } + finally { + // Remove from resolving set + lock (_resolvingPackages) { + _resolvingPackages.Remove(packageId); + } + } + } + + /// + /// Recursively collects all packages from a dependency node into a flat list + /// + private void CollectAllPackages(DependencyGraphNode node, List packages, bool isRoot = false) { + packages.Add(new PackageReference { + PackageId = node.PackageId, + Version = node.Version, + TargetFramework = node.TargetFramework, + IsPrerelease = node.IsPrerelease, + IsRootPackage = isRoot, + Depth = node.Depth, + VersionRange = node.VersionRange, + RetrievedAt = node.RetrievedAt + }); + + foreach (var child in node.Dependencies) { + CollectAllPackages(child, packages, false); + } + } + + /// + /// Creates a cache key for package lookup + /// + private static string CreateCacheKey(string packageId, IReadOnlyList targetFrameworks) { + var frameworksKey = !targetFrameworks.Any() ? "any" : string.Join(",", targetFrameworks.Select(f => f.GetShortFolderName()).OrderBy(f => f)); + return $"{packageId}|{frameworksKey}"; + } +} \ No newline at end of file diff --git a/bld/Services/NuGet/ReverseDependencyGraphService.cs b/bld/Services/NuGet/ReverseDependencyGraphService.cs new file mode 100644 index 0000000..e04bbdd --- /dev/null +++ b/bld/Services/NuGet/ReverseDependencyGraphService.cs @@ -0,0 +1,166 @@ +using bld.Infrastructure; +using bld.Models; + +namespace bld.Services.NuGet; + +/// +/// Service for building reverse dependency graphs from forward dependency graphs +/// +internal sealed class ReverseDependencyGraphService { + private readonly IConsoleOutput? _console; + + public ReverseDependencyGraphService(IConsoleOutput? console) { + _console = console; // Allow null for testing + } + + /// + /// Builds a reverse dependency graph from a forward dependency graph + /// + /// The forward dependency graph + /// Whether to exclude Microsoft/System/NETStandard packages + /// Reverse dependency analysis + public ReverseDependencyAnalysis BuildReverseDependencyGraph( + PackageDependencyGraph forwardGraph, + bool excludeFrameworkPackages = false) { + + ArgumentNullException.ThrowIfNull(forwardGraph); + + var reverseMapping = new Dictionary(); + var explicitPackages = new HashSet(); + + // Track explicit (root) packages + foreach (var rootNode in forwardGraph.RootPackages) { + explicitPackages.Add(rootNode.PackageId); + } + + // Build reverse mappings from all packages + foreach (var package in forwardGraph.AllPackages) { + if (excludeFrameworkPackages && IsFrameworkPackage(package.PackageId)) { + continue; + } + + // Ensure the package exists in reverse mapping + if (!reverseMapping.TryGetValue(package.PackageId, out var reverseNode)) { + reverseNode = new ReverseDependencyNode { + PackageId = package.PackageId, + Version = package.Version, + TargetFramework = package.TargetFramework, + IsExplicit = explicitPackages.Contains(package.PackageId), + IsFrameworkPackage = IsFrameworkPackage(package.PackageId), + DependentPackages = new List(), + DependencyPaths = new List() + }; + reverseMapping[package.PackageId] = reverseNode; + } + } + + // Build reverse dependencies by walking the forward graph + foreach (var rootNode in forwardGraph.RootPackages) { + BuildReverseMappingsRecursive(rootNode, null, reverseMapping, excludeFrameworkPackages, new List()); + } + + // Calculate statistics + var analysis = new ReverseDependencyAnalysis { + ReverseNodes = reverseMapping.Values.ToList(), + TotalPackages = reverseMapping.Count, + ExplicitPackages = reverseMapping.Values.Count(n => n.IsExplicit), + TransitivePackages = reverseMapping.Values.Count(n => !n.IsExplicit), + FrameworkPackages = reverseMapping.Values.Count(n => n.IsFrameworkPackage), + MostReferencedPackages = reverseMapping.Values + .OrderByDescending(n => n.DependentPackages.Count) + .Take(10) + .ToList(), + LeafPackages = reverseMapping.Values + .Where(n => n.DependentPackages.Count == 0) + .OrderBy(n => n.PackageId) + .ToList() + }; + + return analysis; + } + + /// + /// Recursively builds reverse dependency mappings + /// + private void BuildReverseMappingsRecursive( + DependencyGraphNode currentNode, + DependencyGraphNode? parentNode, + Dictionary reverseMapping, + bool excludeFrameworkPackages, + List currentPath) { + + var newPath = new List(currentPath) { currentNode.PackageId }; + + // If this node has a parent, add the parent as a dependent + if (parentNode != null) { + if (reverseMapping.TryGetValue(currentNode.PackageId, out var reverseNode)) { + // Add parent as a dependent if not already present + if (!reverseNode.DependentPackages.Any(d => d.PackageId == parentNode.PackageId)) { + reverseNode.DependentPackages.Add(new PackageReference { + PackageId = parentNode.PackageId, + Version = parentNode.Version, + TargetFramework = parentNode.TargetFramework, + IsRootPackage = parentNode.Depth == 0, // Root packages have depth 0 + Depth = parentNode.Depth, + IsPrerelease = parentNode.IsPrerelease, + VersionRange = parentNode.VersionRange + }); + } + + // Add dependency path + var pathString = string.Join(" โ†’ ", newPath); + if (!reverseNode.DependencyPaths.Contains(pathString)) { + reverseNode.DependencyPaths.Add(pathString); + } + } + } + + // Continue with dependencies + foreach (var dependency in currentNode.Dependencies) { + if (!excludeFrameworkPackages || !IsFrameworkPackage(dependency.PackageId)) { + BuildReverseMappingsRecursive(dependency, currentNode, reverseMapping, excludeFrameworkPackages, newPath); + } + } + } + + /// + /// Determines if a package is a framework package + /// + private static bool IsFrameworkPackage(string packageId) { + var lowerId = packageId.ToLowerInvariant(); + return lowerId.StartsWith("microsoft.") || + lowerId.StartsWith("system.") || + lowerId.StartsWith("netstandard.") || + lowerId.StartsWith("runtime.") || + lowerId.StartsWith("internal.aspnetcore.") || + lowerId == "netstandard.library"; + } +} + +/// +/// Analysis results for reverse dependency graph +/// +internal sealed class ReverseDependencyAnalysis { + public List ReverseNodes { get; set; } = new(); + public int TotalPackages { get; set; } + public int ExplicitPackages { get; set; } + public int TransitivePackages { get; set; } + public int FrameworkPackages { get; set; } + public List MostReferencedPackages { get; set; } = new(); + public List LeafPackages { get; set; } = new(); +} + +/// +/// Represents a node in the reverse dependency graph +/// +internal sealed class ReverseDependencyNode { + public string PackageId { get; set; } = string.Empty; + public string Version { get; set; } = string.Empty; + public string TargetFramework { get; set; } = string.Empty; + public bool IsExplicit { get; set; } + public bool IsFrameworkPackage { get; set; } + public List DependentPackages { get; set; } = new(); + public List DependencyPaths { get; set; } = new(); + + public int ReferenceCount => DependentPackages.Count; +} \ No newline at end of file diff --git a/bld/Services/NuGet/VulnerabilityService.cs b/bld/Services/NuGet/VulnerabilityService.cs new file mode 100644 index 0000000..0b10a35 --- /dev/null +++ b/bld/Services/NuGet/VulnerabilityService.cs @@ -0,0 +1,223 @@ +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using bld.Infrastructure; +using bld.Models; +using NuGet.Versioning; + +namespace bld.Services.NuGet; + +/// +/// Service for retrieving NuGet package vulnerability information from GitHub Security Advisories +/// +internal class VulnerabilityService { + private readonly HttpClient _httpClient; + private readonly IConsoleOutput _console; + private readonly Dictionary> _vulnerabilityCache = new(); + + public VulnerabilityService(HttpClient httpClient, IConsoleOutput console) { + _httpClient = httpClient; + _console = console; + } + + /// + /// Gets vulnerability information for multiple packages + /// + public async Task>> GetVulnerabilitiesAsync( + IEnumerable packageIds, + CancellationToken cancellationToken = default) { + + var result = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var uncachedPackages = new List(); + + // Check cache first + foreach (var packageId in packageIds) { + if (_vulnerabilityCache.TryGetValue(packageId, out var cached)) { + result[packageId] = cached; + } else { + uncachedPackages.Add(packageId); + } + } + + if (!uncachedPackages.Any()) { + return result; + } + + _console.WriteDebug($"Fetching vulnerability data for {uncachedPackages.Count} packages..."); + + // Batch fetch vulnerabilities + var vulnerabilities = await FetchVulnerabilitiesFromGitHubAsync(uncachedPackages, cancellationToken); + + foreach (var packageId in uncachedPackages) { + var packageVulns = vulnerabilities.Where(v => + string.Equals(v.PackageId, packageId, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + _vulnerabilityCache[packageId] = packageVulns; + result[packageId] = packageVulns; + } + + return result; + } + + /// + /// Checks if a specific package version is vulnerable + /// + public async Task IsPackageVulnerableAsync( + string packageId, + string version, + CancellationToken cancellationToken = default) { + + var vulnerabilities = await GetVulnerabilitiesAsync([packageId], cancellationToken); + + if (!vulnerabilities.TryGetValue(packageId, out var packageVulns) || !packageVulns.Any()) { + return false; + } + + if (!NuGetVersion.TryParse(version, out var nugetVersion)) { + return false; + } + + return packageVulns.Any(vuln => { + if (VersionRange.TryParse(vuln.AffectedVersionRange, out var range)) { + return range.Satisfies(nugetVersion); + } + return false; + }); + } + + private async Task> FetchVulnerabilitiesFromGitHubAsync( + IEnumerable packageIds, + CancellationToken cancellationToken) { + + var vulnerabilities = new List(); + + try { + // Use GitHub Security Advisories API to get NuGet vulnerabilities + // This is a simplified version - in reality you'd need proper pagination and error handling + var packageList = string.Join(",", packageIds.Select(p => $"\"{p}\"")); + + // GitHub GraphQL query for security advisories + var query = $$""" + { + securityAdvisories(first: 100, ecosystem: NUGET) { + nodes { + ghsaId + summary + description + severity + publishedAt + vulnerabilities(first: 10) { + nodes { + package { + name + } + vulnerableVersionRange + firstPatchedVersion { + identifier + } + } + } + } + } + } + """; + + var requestBody = new { + query = query + }; + + // Note: This is a mock implementation - GitHub requires authentication + // In a real implementation, you'd need to handle: + // 1. GitHub API authentication + // 2. Rate limiting + // 3. Proper error handling + // 4. Package name filtering + + _console.WriteDebug("Mock vulnerability check - returning empty results"); + + // For now, return mock vulnerabilities for demonstration + foreach (var packageId in packageIds.Take(2)) { + if (packageId.Contains("Newtonsoft", StringComparison.OrdinalIgnoreCase)) { + vulnerabilities.Add(new PackageVulnerability { + PackageId = packageId, + AffectedVersionRange = "< 13.0.1", + AdvisoryUrl = "https://github.com/advisories/GHSA-5crp-9r3c-p9vr", + Severity = "High", + Title = "Improper Handling of Exceptional Conditions in Newtonsoft.Json", + Description = "Newtonsoft.Json prior to version 13.0.1 is vulnerable to insecure deserialization.", + PublishedDate = DateTime.Parse("2023-02-18"), + CvssScore = "7.5" + }); + } + } + } + catch (Exception ex) { + _console.WriteWarning($"Failed to fetch vulnerability data: {ex.Message}"); + } + + return vulnerabilities; + } +} + +// DTOs for GitHub Security Advisories API responses +internal record GitHubSecurityAdvisoryResponse { + [JsonPropertyName("data")] + public SecurityAdvisoryData? Data { get; set; } +} + +internal record SecurityAdvisoryData { + [JsonPropertyName("securityAdvisories")] + public SecurityAdvisoryNodes? SecurityAdvisories { get; set; } +} + +internal record SecurityAdvisoryNodes { + [JsonPropertyName("nodes")] + public List Nodes { get; set; } = []; +} + +internal record SecurityAdvisory { + [JsonPropertyName("ghsaId")] + public string GhsaId { get; set; } = ""; + + [JsonPropertyName("summary")] + public string Summary { get; set; } = ""; + + [JsonPropertyName("description")] + public string Description { get; set; } = ""; + + [JsonPropertyName("severity")] + public string Severity { get; set; } = ""; + + [JsonPropertyName("publishedAt")] + public DateTime PublishedAt { get; set; } + + [JsonPropertyName("vulnerabilities")] + public VulnerabilityNodes? Vulnerabilities { get; set; } +} + +internal record VulnerabilityNodes { + [JsonPropertyName("nodes")] + public List Nodes { get; set; } = []; +} + +internal record VulnerabilityInfo { + [JsonPropertyName("package")] + public GitHubPackageInfo? Package { get; set; } + + [JsonPropertyName("vulnerableVersionRange")] + public string VulnerableVersionRange { get; set; } = ""; + + [JsonPropertyName("firstPatchedVersion")] + public PatchedVersion? FirstPatchedVersion { get; set; } +} + +internal record GitHubPackageInfo { + [JsonPropertyName("name")] + public string Name { get; set; } = ""; +} + +internal record PatchedVersion { + [JsonPropertyName("identifier")] + public string Identifier { get; set; } = ""; +} \ No newline at end of file diff --git a/bld/Services/OutdatedService.cs b/bld/Services/OutdatedService.cs index ee2f822..45cacb3 100644 --- a/bld/Services/OutdatedService.cs +++ b/bld/Services/OutdatedService.cs @@ -216,7 +216,7 @@ await _console.StartStatusAsync($"Processing solution {slnPath}", async ctx => { // Prepare batch updates: props file -> (package -> version) and project -> (package -> version) var propsUpdates = new Dictionary>(StringComparer.OrdinalIgnoreCase); - var projectUpdates = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var projectUpdates = new Dictionary>(StringComparer.OrdinalIgnoreCase); static bool HasVersionUpdate(string latest, string current) { if (string.IsNullOrWhiteSpace(current)) return true; @@ -246,7 +246,7 @@ static bool HasVersionUpdate(string latest, string current) { //else if (!usage.FromProps) { else { if (!projectUpdates.TryGetValue(usage.ProjectPath, out var pmap)) { - pmap = new Dictionary(StringComparer.OrdinalIgnoreCase); + pmap = new Dictionary(StringComparer.OrdinalIgnoreCase); projectUpdates[usage.ProjectPath] = pmap; } static VersionReason Reason(Pkg item) { @@ -262,7 +262,7 @@ static VersionReason Reason(Pkg item) { ////////// /// { - + foreach (var kvp in propsUpdates.OrderBy(kvp => kvp.Key)) { if (!kvp.Value.Any()) continue; @@ -286,7 +286,7 @@ static VersionReason Reason(Pkg item) { { foreach (var kvp in projectUpdates.OrderBy(kvp => kvp.Key)) { if (!kvp.Value.Any()) continue; - + _console.WriteHeader($"{kvp.Key}", "Version upgrades to project file."); var table = new Table().Border(TableBorder.Rounded); table.AddColumn(new TableColumn("Package").LeftAligned()); @@ -423,90 +423,90 @@ private async Task UpdatePackageVersionAsync(string projectPath, string packageI } } - internal class PackageInfoContainer : IEnumerable { - private readonly HashSet _items = new(new PackageInfoComparer()); - internal void Add(PackageInfo item) { - if (item.TargetFrameworks is { } && item.TargetFrameworks.Length > 0) { - for (int odx = 0; odx < item.TargetFrameworks.Length; odx++) { - var nuTfm = NuGetFramework.Parse(item.TargetFrameworks[odx]); - _tfms.Add(nuTfm); - } - } - else if (item.TargetFramework is { }) { - var nuTfm = NuGetFramework.Parse(item.TargetFramework); - _tfms.Add(nuTfm); +} - } - var added = _items.Add(item); - if (!added) { +internal class PackageInfoContainer : IEnumerable { + private readonly HashSet _items = new(new PackageInfoComparer()); + internal void Add(PackageInfo item) { + if (item.TargetFrameworks is { } && item.TargetFrameworks.Length > 0) { + for (int odx = 0; odx < item.TargetFrameworks.Length; odx++) { + var nuTfm = NuGetFramework.Parse(item.TargetFrameworks[odx]); + _tfms.Add(nuTfm); } } + else if (item.TargetFramework is { }) { + var nuTfm = NuGetFramework.Parse(item.TargetFramework); + _tfms.Add(nuTfm); - internal void AddRange(IEnumerable exnm) { - foreach (var item in exnm) Add(item); } + var added = _items.Add(item); + if (!added) { + } + } - public IEnumerable Tfms => _tfms.Select(nuTfm => nuTfm.GetShortFolderName()); - public string? Tfm => _tfms.Count() == 1 ? _tfms.First().GetShortFolderName() : default; - private readonly HashSet _tfms = new(); - - public IEnumerator GetEnumerator() => _items.GetEnumerator(); - System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => _items.GetEnumerator(); + internal void AddRange(IEnumerable exnm) { + foreach (var item in exnm) Add(item); } - internal sealed class PackageInfoComparer : IEqualityComparer { - public bool Equals(PackageInfo? x, PackageInfo? y) { - if (ReferenceEquals(x, y)) return true; - if (x is null || y is null) return false; - return string.Equals(x.Id, y.Id, StringComparison.OrdinalIgnoreCase) - && string.Equals(x.Version, y.Version, StringComparison.OrdinalIgnoreCase) - && string.Equals(x.ProjectPath, y.ProjectPath, StringComparison.OrdinalIgnoreCase) - && - //string.Equals(x.TargetFramework, y.TargetFramework, StringComparison.OrdinalIgnoreCase) - //&& ((x.TargetFrameworks == null && y.TargetFrameworks == null) || - (x.TargetFrameworks != null && y.TargetFrameworks != null && - x.TargetFrameworks.SequenceEqual(y.TargetFrameworks, StringComparer.OrdinalIgnoreCase)) - //) - && string.Equals(x.PropsPath, y.PropsPath, StringComparison.OrdinalIgnoreCase) - && x.FromProps == y.FromProps; - } + public IEnumerable Tfms => _tfms.Select(nuTfm => nuTfm.GetShortFolderName()); + public string? Tfm => _tfms.Count() == 1 ? _tfms.First().GetShortFolderName() : default; + private readonly HashSet _tfms = new(); - public int GetHashCode(PackageInfo obj) { - if (obj is null) return 0; - int hash = 17; - hash = hash * 23 + (obj.Id?.ToLowerInvariant().GetHashCode() ?? 0); - hash = hash * 23 + (obj.Version?.ToLowerInvariant().GetHashCode() ?? 0); - hash = hash * 23 + (obj.ProjectPath?.ToLowerInvariant().GetHashCode() ?? 0); - //hash = hash * 23 + (obj.TargetFramework?.ToLowerInvariant().GetHashCode() ?? 0); - if (obj.TargetFrameworks != null) { - foreach (var tfm in obj.TargetFrameworks) { - hash = hash * 23 + (tfm?.ToLowerInvariant().GetHashCode() ?? 0); - } + public IEnumerator GetEnumerator() => _items.GetEnumerator(); + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => _items.GetEnumerator(); +} + +internal sealed class PackageInfoComparer : IEqualityComparer { + public bool Equals(PackageInfo? x, PackageInfo? y) { + if (ReferenceEquals(x, y)) return true; + if (x is null || y is null) return false; + return string.Equals(x.Id, y.Id, StringComparison.OrdinalIgnoreCase) + && string.Equals(x.Version, y.Version, StringComparison.OrdinalIgnoreCase) + && string.Equals(x.ProjectPath, y.ProjectPath, StringComparison.OrdinalIgnoreCase) + && + //string.Equals(x.TargetFramework, y.TargetFramework, StringComparison.OrdinalIgnoreCase) + //&& ((x.TargetFrameworks == null && y.TargetFrameworks == null) || + (x.TargetFrameworks != null && y.TargetFrameworks != null && + x.TargetFrameworks.SequenceEqual(y.TargetFrameworks, StringComparer.OrdinalIgnoreCase)) + //) + && string.Equals(x.PropsPath, y.PropsPath, StringComparison.OrdinalIgnoreCase) + && x.FromProps == y.FromProps; + } + + public int GetHashCode(PackageInfo obj) { + if (obj is null) return 0; + int hash = 17; + hash = hash * 23 + (obj.Id?.ToLowerInvariant().GetHashCode() ?? 0); + hash = hash * 23 + (obj.Version?.ToLowerInvariant().GetHashCode() ?? 0); + hash = hash * 23 + (obj.ProjectPath?.ToLowerInvariant().GetHashCode() ?? 0); + //hash = hash * 23 + (obj.TargetFramework?.ToLowerInvariant().GetHashCode() ?? 0); + if (obj.TargetFrameworks != null) { + foreach (var tfm in obj.TargetFrameworks) { + hash = hash * 23 + (tfm?.ToLowerInvariant().GetHashCode() ?? 0); } - hash = hash * 23 + (obj.PropsPath?.ToLowerInvariant().GetHashCode() ?? 0); - hash = hash * 23 + obj.FromProps.GetHashCode(); - return hash; } + hash = hash * 23 + (obj.PropsPath?.ToLowerInvariant().GetHashCode() ?? 0); + hash = hash * 23 + obj.FromProps.GetHashCode(); + return hash; } +} - internal record class PackageInfo { - public string Id { get; set; } = string.Empty; - public Pkg Item { get; set; } = default!; - - public string Version => Item.EffectiveVersion; // { get; set; } = string.Empty; +internal record class PackageInfo { + public string Id { get; set; } = string.Empty; + public Pkg Item { get; set; } = default!; - public string ProjectPath { get; set; } = string.Empty; - public string TargetFramework { get; set; } = default!; - public string[] TargetFrameworks { get; set; } = default!; - public string? PropsPath { get; set; } - public bool FromProps { get; set; } - - public bool CustomVersion => !string.IsNullOrWhiteSpace(Item.Version) || !string.IsNullOrWhiteSpace(Item.VersionOverride); - } + public string Version => Item.EffectiveVersion; // { get; set; } = string.Empty; + public string ProjectPath { get; set; } = string.Empty; + public string TargetFramework { get; set; } = default!; + public string[] TargetFrameworks { get; set; } = default!; + public string? PropsPath { get; set; } + public bool FromProps { get; set; } + public bool CustomVersion => !string.IsNullOrWhiteSpace(Item.Version) || !string.IsNullOrWhiteSpace(Item.VersionOverride); } + internal enum VersionReason { PackageReferenceProj, VersionOverrideProj, diff --git a/bld/Services/PackageDiscoveryService.cs b/bld/Services/PackageDiscoveryService.cs new file mode 100644 index 0000000..71cd68c --- /dev/null +++ b/bld/Services/PackageDiscoveryService.cs @@ -0,0 +1,90 @@ +using bld.Infrastructure; +using bld.Models; +using System.Diagnostics; +using System.Runtime.CompilerServices; + +namespace bld.Services; + +/// +/// Service responsible for discovering package references from solutions and projects +/// +internal class PackageDiscoveryService { + private readonly IConsoleOutput _console; + private readonly CleaningOptions _options; + + public PackageDiscoveryService(IConsoleOutput console, CleaningOptions options) { + _console = console; + _options = options; + } + + /// + /// Discovers all package references from the specified root path + /// + /// Root path to scan for solutions/projects + /// Cancellation token + /// Dictionary of discovered package references + [MethodImpl(MethodImplOptions.NoInlining)] + public async Task<(Dictionary PackageReferences, int ProjectCount, ErrorSink ErrorSink)> DiscoverPackageReferencesAsync( + string rootPath, + CancellationToken cancellationToken = default) { + + MSBuildService.RegisterMSBuildDefaults(_console, _options); + + var errorSink = new ErrorSink(_console); + var slnScanner = new SlnScanner(_options, errorSink); + var slnParser = new SlnParser(_console, errorSink); + var fileSystem = new FileSystem(_console, errorSink); + var cache = new ProjCfgCache(_console); + + var allPackageReferences = new Dictionary(StringComparer.OrdinalIgnoreCase); + + try { + var projParser = new ProjParser(_console, errorSink, _options); + + await foreach (var slnPath in slnScanner.Enumerate(rootPath)) { + await _console.StartStatusAsync($"Processing solution {slnPath}", async ctx => { + await foreach (var projCfg in slnParser.ParseSolution(slnPath, fileSystem)) { + var packageRefs = new PackageInfoContainer(); + + if (!string.Equals(projCfg.Configuration, "Release", StringComparison.OrdinalIgnoreCase)) continue; + if (!cache.Add(projCfg)) continue; + + var refs = projParser.GetPackageReferences(projCfg); + if (refs?.PackageReferences is null || !refs.PackageReferences.Any()) { + _console.WriteDebug($"No references in {projCfg.Path}"); + continue; + } + + var exnm = refs.PackageReferences.Select(re => new PackageInfo { + Id = re.Key, + FromProps = refs.UseCpm ?? false, + TargetFramework = refs.TargetFramework, + TargetFrameworks = refs.TargetFrameworks, + ProjectPath = refs.Proj.Path, + PropsPath = refs.CpmFile, + Item = re.Value + }); + + var bad = exnm.Where(e => string.IsNullOrEmpty(e.Version)).ToList(); + if (bad.Any()) _console.WriteWarning($"Project {projCfg.Path} has package references with no resolvable version: {string.Join(", ", bad.Select(b => b.Id))}"); + packageRefs.AddRange(exnm); + + foreach (var pkg in packageRefs) { + if (!allPackageReferences.TryGetValue(pkg.Id, out var list)) { + list = new PackageInfoContainer(); + allPackageReferences[pkg.Id] = list; + } + list.Add(pkg); + } + } + }); + } + } + catch (Exception ex) { + _console.WriteException(ex); + throw; + } + + return (allPackageReferences, cache.Count, errorSink); + } +} \ No newline at end of file diff --git a/bld/Services/ReverseDependencyTreeVisualizer.cs b/bld/Services/ReverseDependencyTreeVisualizer.cs new file mode 100644 index 0000000..63ae2c7 --- /dev/null +++ b/bld/Services/ReverseDependencyTreeVisualizer.cs @@ -0,0 +1,279 @@ +using bld.Infrastructure; +using bld.Services.NuGet; +using Spectre.Console; + +namespace bld.Services; + +/// +/// Visualizer for reverse dependency graphs using Spectre.Console +/// +internal sealed class ReverseDependencyTreeVisualizer { + private readonly IConsoleOutput? _console; + + public ReverseDependencyTreeVisualizer(IConsoleOutput? console) { + _console = console; // Allow null for testing + } + + /// + /// Displays the reverse dependency analysis with rich visualization + /// + public async Task DisplayReverseDependencyAnalysisAsync( + ReverseDependencyAnalysis analysis, + bool excludeFrameworkPackages = false, + CancellationToken cancellationToken = default) { + + ArgumentNullException.ThrowIfNull(analysis); + + // Header + _console?.WriteRule("[bold cyan]Reverse Dependency Analysis[/]"); + _console?.WriteInfo($"Shows which packages depend on each package (reverse of standard dependency tree)"); + + if (excludeFrameworkPackages) { + _console?.WriteInfo("[dim]Framework packages (Microsoft.*/System.*/NETStandard.*) are excluded[/]"); + } + + // Summary statistics + DisplaySummaryStatistics(analysis); + + // Most referenced packages section disabled as requested + // DisplayMostReferencedPackages(analysis); + + // Detailed reverse dependency tree + await DisplayDetailedReverseDependenciesAsync(analysis, cancellationToken); + + // Leaf packages and categorization are disabled as requested + // DisplayLeafPackages(analysis); + // DisplayPackageCategorization(analysis); + } + + /// + /// Displays summary statistics + /// + private void DisplaySummaryStatistics(ReverseDependencyAnalysis analysis) { + _console?.WriteRule("[bold green]Summary Statistics[/]"); + + var summaryTable = new Table().Border(TableBorder.Simple); + summaryTable.AddColumn("Metric"); + summaryTable.AddColumn("Count"); + summaryTable.AddColumn("Percentage"); + + summaryTable.AddRow("๐Ÿ“ฆ Total Packages", analysis.TotalPackages.ToString(), "100%"); + summaryTable.AddRow("๐ŸŽฏ Explicit References", analysis.ExplicitPackages.ToString(), + $"{(analysis.ExplicitPackages * 100.0 / Math.Max(analysis.TotalPackages, 1)):F1}%"); + summaryTable.AddRow("๐Ÿ“„ Transitive References", analysis.TransitivePackages.ToString(), + $"{(analysis.TransitivePackages * 100.0 / Math.Max(analysis.TotalPackages, 1)):F1}%"); + summaryTable.AddRow("๐Ÿข Framework Packages", analysis.FrameworkPackages.ToString(), + $"{(analysis.FrameworkPackages * 100.0 / Math.Max(analysis.TotalPackages, 1)):F1}%"); + + _console?.WriteTable(summaryTable); + } + + /// + /// Displays the most referenced packages + /// + private void DisplayMostReferencedPackages(ReverseDependencyAnalysis analysis) { + if (!analysis.MostReferencedPackages.Any()) { + return; + } + + _console?.WriteRule("[bold yellow]Most Referenced Packages[/]"); + _console?.WriteInfo("Packages that are dependencies of the most other packages:"); + + var refTable = new Table().Border(TableBorder.Simple); + refTable.AddColumn("Package"); + refTable.AddColumn("Reference Count"); + refTable.AddColumn("Type"); + refTable.AddColumn("Version"); + + foreach (var package in analysis.MostReferencedPackages) { + if (package.ReferenceCount == 0) continue; + + var typeIcon = package.IsExplicit ? "๐ŸŽฏ" : "๐Ÿ“„"; + var frameworkIcon = package.IsFrameworkPackage ? "๐Ÿข" : "๐ŸŒ"; + var packageType = package.IsExplicit ? + $"{typeIcon} [green]Explicit[/] {frameworkIcon}" : + $"{typeIcon} [yellow]Transitive[/] {frameworkIcon}"; + + refTable.AddRow( + Markup.Escape(package.PackageId), + package.ReferenceCount.ToString(), + packageType, + Markup.Escape(package.Version) + ); + } + + _console?.WriteTable(refTable); + } + + /// + /// Displays detailed reverse dependencies for each package + /// + private async Task DisplayDetailedReverseDependenciesAsync( + ReverseDependencyAnalysis analysis, + CancellationToken cancellationToken) { + + _console?.WriteRule("[bold blue]Detailed Reverse Dependencies[/]"); + _console?.WriteInfo("For each package, shows which other packages depend on it:"); + + var packagesWithDependents = analysis.ReverseNodes + .Where(n => n.DependentPackages.Any()) + .OrderByDescending(n => n.ReferenceCount) + .ThenBy(n => n.PackageId) + .ToList(); + + if (!packagesWithDependents.Any()) { + _console?.WriteWarning("No packages with dependents found."); + return; + } + + // Display all packages with dependents (no limit per user request) + var packagesToShow = packagesWithDependents.ToList(); + + foreach (var package in packagesToShow) { + cancellationToken.ThrowIfCancellationRequested(); + + var tree = new Tree($"๐ŸŽฏ [bold]{Markup.Escape(package.PackageId)}[/] [dim]v{Markup.Escape(package.Version)}[/]"); + tree.Style = package.IsExplicit ? Style.Parse("green") : Style.Parse("yellow"); + + // Add package info + var infoNode = tree.AddNode($"๐Ÿ“Š [bold]Referenced by {package.ReferenceCount} package(s)[/]"); + + // Add type info + var typeInfo = package.IsExplicit ? "๐ŸŽฏ Explicit reference" : "๐Ÿ“„ Transitive dependency"; + if (package.IsFrameworkPackage) { + typeInfo += " ๐Ÿข Framework package"; + } + infoNode.AddNode(typeInfo); + + // Add dependent packages + if (package.DependentPackages.Any()) { + var dependentsNode = tree.AddNode($"๐Ÿ“ฆ [bold]Dependent Packages[/]"); + + var groupedDependents = package.DependentPackages + .GroupBy(d => d.PackageId) + .OrderBy(g => g.Key) + .ToList(); + + foreach (var dependentGroup in groupedDependents) { + var dependent = dependentGroup.First(); + var icon = dependent.IsRootPackage ? "๐ŸŽฏ" : "๐Ÿ“„"; + var color = dependent.IsRootPackage ? "green" : "yellow"; + + dependentsNode.AddNode($"{icon} [{color}]{Markup.Escape(dependent.PackageId)}[/] [dim]v{Markup.Escape(dependent.Version)}[/]"); + } + } + + // Add all dependency paths (no limit per user request) + if (package.DependencyPaths.Any()) { + var pathsNode = tree.AddNode($"๐Ÿ›ค๏ธ [bold]Dependency Paths[/]"); + + foreach (var path in package.DependencyPaths) { + pathsNode.AddNode($"[dim]{Markup.Escape(path)}[/]"); + } + } + + AnsiConsole.Write(tree); + _console?.WriteInfo(""); // Add spacing + + // Add a small delay to allow for cancellation + await Task.Delay(1, cancellationToken); + } + + // No limit on display - show all packages per user request + } + + /// + /// Displays leaf packages (packages with no dependents) + /// + private void DisplayLeafPackages(ReverseDependencyAnalysis analysis) { + if (!analysis.LeafPackages.Any()) { + return; + } + + _console?.WriteRule("[bold magenta]Leaf Packages[/]"); + _console?.WriteInfo("Packages that have no other packages depending on them:"); + + var leafTable = new Table().Border(TableBorder.Simple); + leafTable.AddColumn("Package"); + leafTable.AddColumn("Version"); + leafTable.AddColumn("Type"); + leafTable.AddColumn("Framework"); + + var leafPackagesToShow = analysis.LeafPackages.ToList(); // Show all leaf packages + + foreach (var leafPackage in leafPackagesToShow) { + var typeIcon = leafPackage.IsExplicit ? "๐ŸŽฏ" : "๐Ÿ“„"; + var frameworkIcon = leafPackage.IsFrameworkPackage ? "๐Ÿข" : "๐ŸŒ"; + var packageType = leafPackage.IsExplicit ? + $"{typeIcon} [green]Explicit[/] {frameworkIcon}" : + $"{typeIcon} [yellow]Transitive[/] {frameworkIcon}"; + + leafTable.AddRow( + Markup.Escape(leafPackage.PackageId), + Markup.Escape(leafPackage.Version), + packageType, + Markup.Escape(leafPackage.TargetFramework) + ); + } + + _console?.WriteTable(leafTable); + + // All leaf packages are now displayed + } + + /// + /// Displays package categorization breakdown + /// + private void DisplayPackageCategorization(ReverseDependencyAnalysis analysis) { + _console?.WriteRule("[bold cyan]Package Categorization[/]"); + + var categories = analysis.ReverseNodes + .GroupBy(n => CategorizePackage(n.PackageId)) + .OrderByDescending(g => g.Count()) + .ToList(); + + var categoryTable = new Table().Border(TableBorder.Simple); + categoryTable.AddColumn("Category"); + categoryTable.AddColumn("Package Count"); + categoryTable.AddColumn("Avg. Reference Count"); + categoryTable.AddColumn("Examples"); + + foreach (var category in categories) { + var avgRefs = category.Average(p => p.ReferenceCount); + var examples = string.Join(", ", category + .OrderByDescending(p => p.ReferenceCount) + .Take(3) + .Select(p => p.PackageId)); + + categoryTable.AddRow( + category.Key, + category.Count().ToString(), + avgRefs.ToString("F1"), + Markup.Escape(examples) + ); + } + + _console?.WriteTable(categoryTable); + } + + /// + /// Categorizes a package based on its ID + /// + private static string CategorizePackage(string packageId) { + var lowerId = packageId.ToLowerInvariant(); + return lowerId switch { + var p when p.StartsWith("microsoft.") => "๐Ÿข [blue]Microsoft[/]", + var p when p.StartsWith("system.") => "๐Ÿข [blue]System[/]", + var p when p.StartsWith("netstandard.") => "๐Ÿข [blue]NETStandard[/]", + var p when p.StartsWith("runtime.") => "๐Ÿข [blue]Runtime[/]", + var p when p.Contains("newtonsoft") => "๐Ÿ“ฆ [green]JSON/Serialization[/]", + var p when p.Contains("logging") => "๐Ÿ“ [cyan]Logging[/]", + var p when p.Contains("test") => "๐Ÿงช [yellow]Testing[/]", + var p when p.Contains("entity") || p.Contains("ef") => "๐Ÿ—ƒ๏ธ [purple]Data/ORM[/]", + var p when p.Contains("http") || p.Contains("web") => "๐ŸŒ [orange3]HTTP/Web[/]", + var p when p.Contains("azure") => "โ˜๏ธ [blue]Azure[/]", + var p when p.Contains("aspnet") => "๐ŸŒ [red]ASP.NET[/]", + _ => "๐Ÿ“ฆ [dim]Third-party[/]" + }; + } +} \ No newline at end of file diff --git a/bld/Services/SpectreConsoleOutput.cs b/bld/Services/SpectreConsoleOutput.cs index f9c4921..c8d15e9 100644 --- a/bld/Services/SpectreConsoleOutput.cs +++ b/bld/Services/SpectreConsoleOutput.cs @@ -16,7 +16,7 @@ public SpectreConsoleOutput(LogLevel logLevel = LogLevel.Warning) { public void WriteInfo(string message) { if (_logLevel <= LogLevel.Info) { - AnsiConsole.MarkupLine($"[blue]INF:[/] {Markup.Escape(message)}"); + AnsiConsole.MarkupLine($"[blue]{Markup.Escape(message)}[/]"); } } @@ -28,25 +28,25 @@ public void WriteOutput(string caption, string? message) { public void WriteWarning(string message) { if (_logLevel <= LogLevel.Warning) { - AnsiConsole.MarkupLine($"[yellow]WRN:[/] {Markup.Escape(message)}"); + AnsiConsole.MarkupLine($"[yellow]{Markup.Escape(message)}[/]"); } } public void WriteError(string message, Exception? exception = default) { if (_logLevel <= LogLevel.Error) { - AnsiConsole.MarkupLine($"[red]ERR:[/] {Markup.Escape(message)}"); + AnsiConsole.MarkupLine($"[red]{Markup.Escape(message)}[/]"); } } public void WriteDebug(string message) { if (_logLevel <= LogLevel.Debug) { - AnsiConsole.MarkupLine($"[grey]DBG:[/] {Markup.Escape(message)}"); + AnsiConsole.MarkupLine($"[grey]{Markup.Escape(message)}[/]"); } } public void WriteVerbose(string message) { if (_logLevel <= LogLevel.Verbose) { - AnsiConsole.MarkupLine($"[grey]VER:[/] {Markup.Escape(message)}"); + AnsiConsole.MarkupLine($"[grey]{Markup.Escape(message)}[/]"); } }