Created
September 16, 2025 16:29
-
-
Save guibranco/ca35bd1049e3473e680bdb1278b40143 to your computer and use it in GitHub Desktop.
Check NuGet conflicts between dependencies
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #r "nuget: NuGet.Protocol, 6.9.1" | |
| #r "nuget: NuGet.Versioning, 6.9.1" | |
| using System; | |
| using System.Collections.Generic; | |
| using System.Diagnostics; | |
| using System.IO; | |
| using System.IO.Compression; | |
| using System.Net.Http; | |
| using System.Text.Json; | |
| using NuGet.Protocol.Core.Types; | |
| using NuGet.Versioning; | |
| Console.WriteLine("🔍 Starting NuGet dependency consistency check..."); | |
| var resolvedPackagesJson = "resolved-packages.json"; | |
| await ExportResolvedPackages(resolvedPackagesJson); | |
| var sw = Stopwatch.StartNew(); | |
| var resolved = await ParseResolvedPackages(jsonFile); | |
| sw.Stop(); | |
| Console.WriteLine($"✅ Loaded resolved packages in {sw.ElapsedMilliseconds}ms"); | |
| var failed = false; | |
| foreach (var pkg in resolved) | |
| { | |
| foreach (var dependent in resolved) | |
| { | |
| if (pkg.Key == dependent.Key) | |
| continue; | |
| var declaredRanges = await GetDeclaredDependencies(dependent.Key, dependent.Value); | |
| if (declaredRanges.TryGetValue(pkg.Key, out var expectedRange)) | |
| { | |
| if (!expectedRange.Satisfies(pkg.Value)) | |
| { | |
| Console.WriteLine( | |
| $"❌ {pkg.Key} @ {pkg.Value} is too new for {dependent.Key}, which requires {expectedRange}" | |
| ); | |
| failed = true; | |
| } | |
| } | |
| } | |
| } | |
| if (failed) | |
| { | |
| Console.WriteLine("🚫 Dependency version conflicts found."); | |
| Environment.Exit(1); | |
| } | |
| else | |
| { | |
| Console.WriteLine("✅ All dependencies are within expected ranges."); | |
| } | |
| async Task ExportResolvedPackages(string outputFile) | |
| { | |
| var psi = new ProcessStartInfo("dotnet", "list package --include-transitive --format json") | |
| { | |
| RedirectStandardOutput = true, | |
| }; | |
| using var process = Process.Start(psi); | |
| var output = await process.StandardOutput.ReadToEndAsync(); | |
| await File.WriteAllTextAsync(outputFile, output); | |
| process.WaitForExit(); | |
| } | |
| async Task<Dictionary<string, NuGetVersion>> ParseResolvedPackages(string jsonFile) | |
| { | |
| using var stream = File.OpenRead(jsonFile); | |
| using var doc = await JsonDocument.ParseAsync(stream); | |
| var dict = new ConcurrentDictionary<string, NuGetVersion>(StringComparer.OrdinalIgnoreCase); | |
| if (!doc.RootElement.TryGetProperty("projects", out var projectsElement)) | |
| { | |
| Console.WriteLine("⚠️ No 'projects' section found."); | |
| return new Dictionary<string, NuGetVersion>(dict); | |
| } | |
| Parallel.ForEach( | |
| projectsElement.EnumerateArray(), | |
| project => | |
| { | |
| if (!project.TryGetProperty("frameworks", out var frameworksElement)) | |
| return; | |
| foreach (var framework in frameworksElement.EnumerateArray()) | |
| { | |
| void ExtractPackages(JsonElement packages) | |
| { | |
| foreach (var pkg in packages.EnumerateArray()) | |
| { | |
| var id = pkg.GetProperty("id").GetString(); | |
| var version = pkg.GetProperty("resolvedVersion").GetString(); | |
| if (!string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(version)) | |
| { | |
| dict.AddOrUpdate( | |
| id, | |
| NuGetVersion.Parse(version), | |
| (_, existing) => | |
| NuGetVersionComparer.Default.Compare( | |
| NuGetVersion.Parse(version), | |
| existing | |
| ) > 0 | |
| ? NuGetVersion.Parse(version) | |
| : existing | |
| ); | |
| } | |
| } | |
| } | |
| if (framework.TryGetProperty("topLevelPackages", out var topLevelPackages)) | |
| ExtractPackages(topLevelPackages); | |
| if (framework.TryGetProperty("transitivePackages", out var transitivePackages)) | |
| ExtractPackages(transitivePackages); | |
| } | |
| } | |
| ); | |
| return new Dictionary<string, NuGetVersion>(dict); | |
| } | |
| async Task<Dictionary<string, VersionRange>> GetDeclaredDependencies( | |
| string packageId, | |
| NuGetVersion version | |
| ) | |
| { | |
| var output = new Dictionary<string, VersionRange>(StringComparer.OrdinalIgnoreCase); | |
| string url = $"https://www.nuget.org/api/v2/package/{packageId}/{version}"; | |
| string tempPath = Path.Combine(Path.GetTempPath(), $"{packageId}.{version}.nupkg"); | |
| using var client = new HttpClient(); | |
| var bytes = await client.GetByteArrayAsync(url); | |
| await File.WriteAllBytesAsync(tempPath, bytes); | |
| using var zip = ZipFile.OpenRead(tempPath); | |
| foreach (var entry in zip.Entries) | |
| { | |
| if (entry.FullName.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase)) | |
| { | |
| using var reader = new StreamReader(entry.Open()); | |
| var content = await reader.ReadToEndAsync(); | |
| var doc = new System.Xml.XmlDocument(); | |
| doc.LoadXml(content); | |
| var nsMgr = new System.Xml.XmlNamespaceManager(doc.NameTable); | |
| nsMgr.AddNamespace("ns", "http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd"); | |
| var dependencies = doc.SelectNodes("//ns:dependency", nsMgr); | |
| foreach (System.Xml.XmlNode node in dependencies) | |
| { | |
| var id = node.Attributes["id"]?.Value; | |
| var range = node.Attributes["version"]?.Value; | |
| if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(range)) | |
| { | |
| output[id] = VersionRange.Parse(range); | |
| } | |
| } | |
| } | |
| } | |
| return output; | |
| } | |
| List<(string Id, NuGetVersion Version)> GetDeclaredDependencies( | |
| string packageId, | |
| NuGetVersion version | |
| ) | |
| { | |
| var deps = new List<(string, NuGetVersion)>(); | |
| var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); | |
| var path = Path.Combine( | |
| userProfile, | |
| ".nuget", | |
| "packages", | |
| packageId.ToLowerInvariant(), | |
| version.ToString().ToLowerInvariant(), | |
| $"{packageId.ToLowerInvariant()}.nuspec" | |
| ); | |
| if (!File.Exists(path)) | |
| { | |
| Console.WriteLine($"❌ .nuspec not found for {packageId} {version} at: {path}"); | |
| return deps; | |
| } | |
| var doc = new XmlDocument(); | |
| doc.Load(path); | |
| var nsmgr = new XmlNamespaceManager(doc.NameTable); | |
| nsmgr.AddNamespace("n", "http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd"); | |
| var dependencyNodes = doc.SelectNodes("//n:dependencies/n:group/n:dependency", nsmgr); | |
| foreach (XmlNode dep in dependencyNodes) | |
| { | |
| var id = dep.Attributes["id"]?.Value; | |
| var ver = dep.Attributes["version"]?.Value; | |
| if (!string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(ver)) | |
| { | |
| try | |
| { | |
| var range = VersionRange.Parse(ver); | |
| if (range.HasLowerBound) | |
| deps.Add((id, range.MinVersion)); | |
| } | |
| catch | |
| { | |
| Console.WriteLine($"⚠️ Could not parse version range for {id}: {ver}"); | |
| } | |
| } | |
| } | |
| return deps; | |
| } | |
| using System.Xml; | |
| using System.Text.RegularExpressions; | |
| public class NuGetConfig | |
| { | |
| public Dictionary<string, string> PackageSources { get; set; } = new(); | |
| public Dictionary<string, List<string>> SourceToPatterns { get; set; } = new(); | |
| // pattern -> list of feeds | |
| public Dictionary<string, List<string>> PatternToSources { get; set; } = new(); | |
| public static NuGetConfig Parse(string configPath) | |
| { | |
| var config = new NuGetConfig(); | |
| var doc = new XmlDocument(); | |
| doc.Load(configPath); | |
| var nsmgr = new XmlNamespaceManager(doc.NameTable); | |
| var packageSourcesNode = doc.SelectSingleNode("//configuration/packageSources"); | |
| if (packageSourcesNode != null) | |
| { | |
| var clear = packageSourcesNode.SelectSingleNode("clear"); | |
| if (clear != null) | |
| { | |
| config.PackageSources.Clear(); | |
| } | |
| foreach (XmlNode add in packageSourcesNode.SelectNodes("add")) | |
| { | |
| var key = add.Attributes["key"]?.Value; | |
| var value = add.Attributes["value"]?.Value; | |
| if (!string.IsNullOrEmpty(key) && !string.IsNullOrEmpty(value)) | |
| { | |
| config.PackageSources[key] = value; | |
| } | |
| } | |
| } | |
| var sourceMappingNode = doc.SelectSingleNode("//configuration/packageSourceMapping"); | |
| if (sourceMappingNode != null) | |
| { | |
| foreach (XmlNode packageSource in sourceMappingNode.SelectNodes("packageSource")) | |
| { | |
| var key = packageSource.Attributes["key"]?.Value; | |
| if (!string.IsNullOrEmpty(key)) | |
| { | |
| config.SourceToPatterns[key] = new List<string>(); | |
| foreach (XmlNode patternNode in packageSource.SelectNodes("package")) | |
| { | |
| var pattern = patternNode.Attributes["pattern"]?.Value; | |
| if (!string.IsNullOrWhiteSpace(pattern)) | |
| { | |
| config.SourceToPatterns[key].Add(pattern); | |
| if (!config.PatternToSources.ContainsKey(pattern)) | |
| config.PatternToSources[pattern] = new List<string>(); | |
| if (config.PackageSources.TryGetValue(key, out var url)) | |
| { | |
| config.PatternToSources[pattern].Add(url); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| return config; | |
| } | |
| // Get all source base URLs | |
| public List<string> GetAllSourceUrls() | |
| { | |
| return PackageSources.Values.ToList(); | |
| } | |
| // Get matching sources for a given package id | |
| public List<string> GetSourcesForPackage(string packageId) | |
| { | |
| var matched = new List<string>(); | |
| foreach (var (pattern, sources) in PatternToSources) | |
| { | |
| if (PatternMatches(packageId, pattern)) | |
| matched.AddRange(sources); | |
| } | |
| return matched.Distinct().ToList(); | |
| } | |
| private static bool PatternMatches(string text, string pattern) | |
| { | |
| var regex = "^" + Regex.Escape(pattern).Replace("\\*", ".*") + "$"; | |
| return Regex.IsMatch(text, regex, RegexOptions.IgnoreCase); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment