From ac7c28b3c517490e26ed25e606bc49eb7f2b7cfd Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Thu, 25 Jun 2026 11:13:31 +1200 Subject: [PATCH 1/4] Add NuGet package analyzer (redundant & conflicting references) with `:analyze packages` CLI Introduces Fallout.NuGet.Analysis, a dependency analyzer that reads the post-restore project.assets.json resolved graph and flags: - direct package references already provided by a referenced project - direct package references already pulled in transitively by another package - the same package resolving to different versions across projects The core library is free of CLI/build coupling so it can be consumed by both. Wires it up as `dotnet fallout :analyze packages [] [--tfm] [--severity] [--exclude]`, emitting findings at a configurable log level and returning a severity-driven exit code. Implements Fallout-build/Fallout#421. Co-Authored-By: Claude Opus 4.8 (1M context) --- fallout.slnx | 2 + src/Fallout.Cli/Fallout.Cli.csproj | 1 + src/Fallout.Cli/Program.Analyze.cs | 207 +++++++++++++++ .../Fallout.NuGet.Analysis.csproj | 11 + src/Fallout.NuGet.Analysis/Models.cs | 163 ++++++++++++ src/Fallout.NuGet.Analysis/PackageAnalyzer.cs | 248 ++++++++++++++++++ .../ProjectAssetsReader.cs | 226 ++++++++++++++++ .../Fallout.NuGet.Analysis.Tests.csproj | 15 ++ .../PackageAnalyzerTests.cs | 197 ++++++++++++++ 9 files changed, 1070 insertions(+) create mode 100644 src/Fallout.Cli/Program.Analyze.cs create mode 100644 src/Fallout.NuGet.Analysis/Fallout.NuGet.Analysis.csproj create mode 100644 src/Fallout.NuGet.Analysis/Models.cs create mode 100644 src/Fallout.NuGet.Analysis/PackageAnalyzer.cs create mode 100644 src/Fallout.NuGet.Analysis/ProjectAssetsReader.cs create mode 100644 tests/Fallout.NuGet.Analysis.Tests/Fallout.NuGet.Analysis.Tests.csproj create mode 100644 tests/Fallout.NuGet.Analysis.Tests/PackageAnalyzerTests.cs diff --git a/fallout.slnx b/fallout.slnx index 0f6ae9699..6413e9fcc 100644 --- a/fallout.slnx +++ b/fallout.slnx @@ -22,6 +22,7 @@ + @@ -43,6 +44,7 @@ + diff --git a/src/Fallout.Cli/Fallout.Cli.csproj b/src/Fallout.Cli/Fallout.Cli.csproj index 996a07f13..b76ee701d 100644 --- a/src/Fallout.Cli/Fallout.Cli.csproj +++ b/src/Fallout.Cli/Fallout.Cli.csproj @@ -14,6 +14,7 @@ + diff --git a/src/Fallout.Cli/Program.Analyze.cs b/src/Fallout.Cli/Program.Analyze.cs new file mode 100644 index 000000000..bf6608fab --- /dev/null +++ b/src/Fallout.Cli/Program.Analyze.cs @@ -0,0 +1,207 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Fallout.Common; +using Fallout.Common.Execution; +using Fallout.Common.IO; +using Fallout.NuGet.Analysis; + +namespace Fallout.Cli; + +partial class Program +{ + public static int Analyze(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + { + PrintInfo(); + Logging.Configure(); + + var subCommand = args.ElementAtOrDefault(0); + if (!string.Equals(subCommand, "packages", StringComparison.OrdinalIgnoreCase)) + { + Host.Error("Usage: fallout :analyze packages [] [--tfm ] [--severity none|trace|normal|warning|error] [--exclude [,...]]"); + return 1; + } + + if (!TryParseAnalyzeArguments(args.Skip(1).ToArray(), out var path, out var tfm, out var severity, out var excludes)) + return 1; + + var projectFiles = ResolveProjectFiles(path); + if (projectFiles == null) + return 1; + + var analyzed = new List(); + var restoreMissing = 0; + foreach (var projectFile in projectFiles) + { + var assetsFile = ProjectAssetsReader.FindAssetsFile(projectFile); + if (assetsFile == null) + { + restoreMissing++; + Host.Verbose($"Skipping {Path.GetFileName(projectFile)} — no obj/project.assets.json (run 'dotnet restore')."); + continue; + } + + try + { + analyzed.AddRange(ProjectAssetsReader.Read(assetsFile)); + } + catch (Exception exception) + { + Host.Warning($"Could not read assets for {Path.GetFileName(projectFile)}: {exception.Message}"); + } + } + + if (analyzed.Count == 0) + { + Host.Warning(restoreMissing > 0 + ? $"No restored projects found ({restoreMissing} project(s) need 'dotnet restore')." + : "No analyzable projects found."); + return 0; + } + + var options = new AnalyzerOptions { TargetFramework = tfm }; + foreach (var exclude in excludes) + options.ExcludedPackageIds.Add(exclude); + + var findings = new PackageAnalyzer().Analyze(analyzed, options); + + Report(findings, severity, analyzed.Count, restoreMissing); + + var failing = severity == LogLevel.Error && findings.Count > 0; + return failing ? 1 : 0; + } + + private static bool TryParseAnalyzeArguments( + string[] args, + out string path, + out string tfm, + out LogLevel severity, + out HashSet excludes) + { + path = null; + tfm = null; + severity = LogLevel.Warning; + excludes = new HashSet(StringComparer.OrdinalIgnoreCase); + + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + switch (arg.ToLowerInvariant()) + { + case "--tfm": + if (++i >= args.Length) { Host.Error("--tfm requires a value."); return false; } + tfm = args[i]; + break; + case "--severity": + if (++i >= args.Length) { Host.Error("--severity requires a value."); return false; } + if (!TryParseSeverity(args[i], out severity)) { Host.Error($"Unknown severity '{args[i]}'."); return false; } + break; + case "--exclude": + if (++i >= args.Length) { Host.Error("--exclude requires a value."); return false; } + foreach (var id in args[i].Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + excludes.Add(id); + break; + default: + if (arg.StartsWith("--", StringComparison.Ordinal)) + { + Host.Error($"Unknown option '{arg}'."); + return false; + } + + path = arg; + break; + } + } + + return true; + } + + private static bool TryParseSeverity(string value, out LogLevel severity) + { + switch (value.ToLowerInvariant()) + { + case "none": severity = (LogLevel)(-1); return true; + case "trace": severity = LogLevel.Trace; return true; + case "normal": case "info": case "information": severity = LogLevel.Normal; return true; + case "warning": case "warn": severity = LogLevel.Warning; return true; + case "error": severity = LogLevel.Error; return true; + default: severity = LogLevel.Warning; return false; + } + } + + private static List ResolveProjectFiles(string path) + { + if (string.IsNullOrEmpty(path)) + return GlobProjects(Directory.GetCurrentDirectory()); + + var full = Path.GetFullPath(path); + + if (File.Exists(full) && full.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase)) + return new List { full }; + + if (Directory.Exists(full)) + return GlobProjects(full); + + // .sln / .slnx (or any file): analyze the projects under its directory. + if (File.Exists(full)) + return GlobProjects(Path.GetDirectoryName(full)); + + Host.Error($"Path not found: {path}"); + return null; + } + + private static List GlobProjects(string directory) + { + return Directory.GetFiles(directory, "*.csproj", SearchOption.AllDirectories) + .Where(x => !ContainsSegment(x, "obj") && !ContainsSegment(x, "bin")) + .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static bool ContainsSegment(string filePath, string segment) + { + return filePath.Contains($"{Path.DirectorySeparatorChar}{segment}{Path.DirectorySeparatorChar}") || + filePath.Contains($"{Path.AltDirectorySeparatorChar}{segment}{Path.AltDirectorySeparatorChar}"); + } + + private static void Report(IReadOnlyList findings, LogLevel severity, int projectCount, int restoreMissing) + { + void Emit(string text) + { + switch (severity) + { + case LogLevel.Trace: Host.Verbose(text); break; + case LogLevel.Normal: Host.Information(text); break; + case LogLevel.Warning: Host.Warning(text); break; + case LogLevel.Error: Host.Error(text); break; + default: break; // none — summary only + } + } + + var redundant = findings.Where(x => x.Kind != FindingKind.VersionConflict).ToList(); + var conflicts = findings.Where(x => x.Kind == FindingKind.VersionConflict).ToList(); + + if (findings.Count == 0) + { + Host.Information($"No redundant or conflicting package references found across {projectCount} project/framework target(s)."); + if (restoreMissing > 0) + Host.Information($"({restoreMissing} project(s) were skipped — not restored.)"); + return; + } + + foreach (var finding in redundant.OrderBy(x => x.Project).ThenBy(x => x.PackageId)) + { + var marker = finding.SafeToRemove ? "can be removed" : "might be removed"; + Emit($"[{finding.Project} ({finding.TargetFramework})] {finding.PackageId} {finding.ResolvedVersion} — {marker}. {finding.Detail}"); + } + + foreach (var finding in conflicts.OrderBy(x => x.PackageId)) + Emit($"[version conflict] {finding.Detail}"); + + Host.Information( + $"Summary: {redundant.Count} redundant reference(s), {conflicts.Count} version conflict(s) across {projectCount} target(s)."); + if (restoreMissing > 0) + Host.Information($"({restoreMissing} project(s) skipped — not restored.)"); + } +} diff --git a/src/Fallout.NuGet.Analysis/Fallout.NuGet.Analysis.csproj b/src/Fallout.NuGet.Analysis/Fallout.NuGet.Analysis.csproj new file mode 100644 index 000000000..0f6d6b7b1 --- /dev/null +++ b/src/Fallout.NuGet.Analysis/Fallout.NuGet.Analysis.csproj @@ -0,0 +1,11 @@ + + + + net10.0 + + + + + + + diff --git a/src/Fallout.NuGet.Analysis/Models.cs b/src/Fallout.NuGet.Analysis/Models.cs new file mode 100644 index 000000000..c9b0b7d9f --- /dev/null +++ b/src/Fallout.NuGet.Analysis/Models.cs @@ -0,0 +1,163 @@ +using System.Collections.Generic; + +namespace Fallout.NuGet.Analysis; + +/// The category of a . +public enum FindingKind +{ + /// A direct package reference already provided by a referenced project. + RedundantViaProject, + + /// A direct package reference already pulled in transitively by another package reference. + RedundantViaPackage, + + /// The same package resolves to different versions across the analyzed projects. + VersionConflict, +} + +/// Options controlling an analysis run. +public sealed class AnalyzerOptions +{ + /// Package ids to ignore entirely (case-insensitive). + public ISet ExcludedPackageIds { get; } = + new HashSet(System.StringComparer.OrdinalIgnoreCase); + + /// When set, only this target framework (short form, e.g. net10.0) is analyzed. + public string TargetFramework { get; set; } +} + +/// A single resolved node in a project's dependency graph. +public sealed class GraphNode +{ + public GraphNode(string key, string name, string version, string type, IReadOnlyList dependencies) + { + Key = key; + Name = name; + Version = version; + Type = type; + Dependencies = dependencies; + } + + /// The assets-file node key, e.g. Newtonsoft.Json/13.0.3. + public string Key { get; } + + public string Name { get; } + + /// The resolved version NuGet settled on for this node. + public string Version { get; } + + /// package or project. + public string Type { get; } + + public IReadOnlyList Dependencies { get; } + + public bool IsProject => string.Equals(Type, "project", System.StringComparison.OrdinalIgnoreCase); +} + +/// An edge from one node to a dependency (by name, with the requested version range). +public sealed class Edge +{ + public Edge(string name, string versionRange) + { + Name = name; + VersionRange = versionRange; + } + + public string Name { get; } + + /// The range the parent requested, e.g. [12.0.1, ). + public string VersionRange { get; } +} + +/// A direct dependency declared by the project (a PackageReference). +public sealed class DirectDependency +{ + public DirectDependency(string name, string versionRange, bool autoReferenced, bool privateAssetsAll, string nodeKey) + { + Name = name; + VersionRange = versionRange; + AutoReferenced = autoReferenced; + PrivateAssetsAll = privateAssetsAll; + NodeKey = nodeKey; + } + + public string Name { get; } + + /// The declared range, e.g. [13.0.3, ). + public string VersionRange { get; } + + /// SDK-implicit reference — cannot be removed from the csproj. + public bool AutoReferenced { get; } + + /// PrivateAssets="all" — does not flow to consumers. + public bool PrivateAssetsAll { get; } + + /// The resolved graph node this reference maps to (may be null if unresolved). + public string NodeKey { get; } +} + +/// The analyzable state of one project at one target framework, read from project.assets.json. +public sealed class AnalyzedProject +{ + public AnalyzedProject( + string projectName, + string projectPath, + string targetFramework, + IReadOnlyList directPackages, + IReadOnlyList directProjectNodeKeys, + IReadOnlyDictionary graph) + { + ProjectName = projectName; + ProjectPath = projectPath; + TargetFramework = targetFramework; + DirectPackages = directPackages; + DirectProjectNodeKeys = directProjectNodeKeys; + Graph = graph; + } + + public string ProjectName { get; } + + public string ProjectPath { get; } + + /// Short form, e.g. net10.0. + public string TargetFramework { get; } + + public IReadOnlyList DirectPackages { get; } + + /// Graph node keys of the project's direct ProjectReferences. + public IReadOnlyList DirectProjectNodeKeys { get; } + + /// The resolved dependency graph keyed by node key (Name/Version). + public IReadOnlyDictionary Graph { get; } +} + +/// A single analyzer finding. +public sealed class Finding +{ + public FindingKind Kind { get; set; } + + /// The project the finding applies to (the one carrying the redundant reference). + public string Project { get; set; } + + public string TargetFramework { get; set; } + + public string PackageId { get; set; } + + /// The resolved version in the graph. + public string ResolvedVersion { get; set; } + + /// The version declared on the direct reference (redundancy findings only). + public string DeclaredVersion { get; set; } + + /// + /// true when the reference can be removed without changing the resolved version; + /// false when removal might lower the resolved version (advisory). Redundancy findings only. + /// + public bool SafeToRemove { get; set; } + + /// The projects/packages that already provide this package. + public IReadOnlyList Providers { get; set; } = new List(); + + /// A human-readable explanation. + public string Detail { get; set; } +} diff --git a/src/Fallout.NuGet.Analysis/PackageAnalyzer.cs b/src/Fallout.NuGet.Analysis/PackageAnalyzer.cs new file mode 100644 index 000000000..de2727087 --- /dev/null +++ b/src/Fallout.NuGet.Analysis/PackageAnalyzer.cs @@ -0,0 +1,248 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NuGet.Versioning; + +namespace Fallout.NuGet.Analysis; + +/// +/// The analysis engine. Works purely off the resolved dependency graph(s) produced by +/// , so it sees exactly what NuGet resolved. +/// +/// The core rule unifies snitch's two notions of "redundant": a direct package reference +/// X is redundant when X is reachable in the resolved graph through some +/// other direct dependency — whether that dependency is a referenced project +/// () or another package +/// (). +/// +public sealed class PackageAnalyzer +{ + /// Run redundancy detection on a single resolved project, plus cross-project version conflicts. + public IReadOnlyList Analyze(IEnumerable projects, AnalyzerOptions options = null) + { + options ??= new AnalyzerOptions(); + var materialized = projects + .Where(x => options.TargetFramework == null || + string.Equals(x.TargetFramework, options.TargetFramework, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + var findings = new List(); + foreach (var project in materialized) + findings.AddRange(AnalyzeRedundancy(project, options)); + + findings.AddRange(FindVersionConflicts(materialized, options)); + return findings; + } + + /// Redundant direct package references for one project at one target framework. + public IReadOnlyList AnalyzeRedundancy(AnalyzedProject project, AnalyzerOptions options = null) + { + options ??= new AnalyzerOptions(); + var findings = new List(); + + var byName = BuildNameIndex(project.Graph); + + // The set of direct dependencies a redundant reference could be "provided" through. + var directPackageKeys = project.DirectPackages + .Where(x => x.NodeKey != null) + .Select(x => x.NodeKey) + .ToList(); + var allDirectKeys = directPackageKeys.Concat(project.DirectProjectNodeKeys).ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var direct in project.DirectPackages) + { + if (direct.NodeKey == null) + continue; + if (direct.AutoReferenced || direct.PrivateAssetsAll) + continue; + if (options.ExcludedPackageIds.Contains(direct.Name)) + continue; + + // Every other direct dependency is a candidate provider. + var otherRoots = allDirectKeys.Where(x => !string.Equals(x, direct.NodeKey, StringComparison.OrdinalIgnoreCase)); + + var providerProjects = new List(); + var providerPackages = new List(); + foreach (var root in otherRoots) + { + var reachable = Reachable(project.Graph, byName, root); + if (!reachable.Contains(direct.NodeKey)) + continue; + + if (project.Graph.TryGetValue(root, out var rootNode)) + { + if (rootNode.IsProject) + providerProjects.Add(rootNode.Name); + else + providerPackages.Add(rootNode.Name); + } + } + + if (providerProjects.Count == 0 && providerPackages.Count == 0) + continue; + + var resolvedVersion = project.Graph[direct.NodeKey].Version; + var safe = IsSafeToRemove(project.Graph, direct, out var wouldResolveTo); + + var providers = providerProjects.Concat(providerPackages).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + var kind = providerProjects.Count > 0 ? FindingKind.RedundantViaProject : FindingKind.RedundantViaPackage; + var via = kind == FindingKind.RedundantViaProject ? "project reference" : "package"; + + var detail = $"{direct.Name} is already provided via {via} {string.Join(", ", providers)}."; + if (!safe) + detail += $" Removing it may downgrade {direct.Name} from {resolvedVersion} to {wouldResolveTo}."; + + findings.Add(new Finding + { + Kind = kind, + Project = project.ProjectName, + TargetFramework = project.TargetFramework, + PackageId = direct.Name, + ResolvedVersion = resolvedVersion, + DeclaredVersion = NormalizeRange(direct.VersionRange), + SafeToRemove = safe, + Providers = providers, + Detail = detail, + }); + } + + return findings; + } + + /// Same package resolved at different versions across the analyzed projects. + public IReadOnlyList FindVersionConflicts(IReadOnlyList projects, AnalyzerOptions options = null) + { + options ??= new AnalyzerOptions(); + var findings = new List(); + + // package id -> (project, version) occurrences across all graphs. + var occurrences = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var project in projects) + { + foreach (var node in project.Graph.Values) + { + if (node.IsProject || string.IsNullOrEmpty(node.Version)) + continue; + if (options.ExcludedPackageIds.Contains(node.Name)) + continue; + + if (!occurrences.TryGetValue(node.Name, out var list)) + occurrences[node.Name] = list = new List<(string, string)>(); + list.Add((project.ProjectName, node.Version)); + } + } + + foreach (var pair in occurrences) + { + var distinctVersions = pair.Value.Select(x => x.Version).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + if (distinctVersions.Count < 2) + continue; + + var breakdown = pair.Value + .GroupBy(x => x.Version, StringComparer.OrdinalIgnoreCase) + .OrderByDescending(g => g.Key) + .Select(g => $"{g.Key} ({string.Join(", ", g.Select(x => x.Project).Distinct())})"); + + findings.Add(new Finding + { + Kind = FindingKind.VersionConflict, + PackageId = pair.Key, + ResolvedVersion = distinctVersions.OrderByDescending(x => x).First(), + Providers = distinctVersions, + Detail = $"{pair.Key} resolves to multiple versions: {string.Join("; ", breakdown)}.", + }); + } + + return findings; + } + + private static bool IsSafeToRemove( + IReadOnlyDictionary graph, + DirectDependency direct, + out string wouldResolveTo) + { + wouldResolveTo = null; + + var declaredMin = ParseMinVersion(direct.VersionRange); + if (declaredMin == null) + return true; + + // Highest version any transitive requester asks for (edges in the graph pointing at this package). + NuGetVersion highestTransitive = null; + foreach (var node in graph.Values) + { + foreach (var edge in node.Dependencies) + { + if (!string.Equals(edge.Name, direct.Name, StringComparison.OrdinalIgnoreCase)) + continue; + + var min = ParseMinVersion(edge.VersionRange); + if (min != null && (highestTransitive == null || min > highestTransitive)) + highestTransitive = min; + } + } + + if (highestTransitive == null) + return true; + + if (declaredMin > highestTransitive) + { + wouldResolveTo = highestTransitive.ToNormalizedString(); + return false; + } + + return true; + } + + private static HashSet Reachable( + IReadOnlyDictionary graph, + IReadOnlyDictionary byName, + string startKey) + { + var visited = new HashSet(StringComparer.OrdinalIgnoreCase); + var queue = new Queue(); + queue.Enqueue(startKey); + + while (queue.Count > 0) + { + var current = queue.Dequeue(); + if (!graph.TryGetValue(current, out var node)) + continue; + + foreach (var edge in node.Dependencies) + { + if (!byName.TryGetValue(edge.Name, out var childKey)) + continue; + if (visited.Add(childKey)) + queue.Enqueue(childKey); + } + } + + return visited; + } + + private static Dictionary BuildNameIndex(IReadOnlyDictionary graph) + { + var index = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var node in graph.Values) + index[node.Name] = node.Key; + return index; + } + + private static NuGetVersion ParseMinVersion(string range) + { + if (string.IsNullOrWhiteSpace(range)) + return null; + + if (VersionRange.TryParse(range, out var parsed) && parsed.MinVersion != null) + return parsed.MinVersion; + + return NuGetVersion.TryParse(range, out var version) ? version : null; + } + + private static string NormalizeRange(string range) + { + var min = ParseMinVersion(range); + return min != null ? min.ToNormalizedString() : range; + } +} diff --git a/src/Fallout.NuGet.Analysis/ProjectAssetsReader.cs b/src/Fallout.NuGet.Analysis/ProjectAssetsReader.cs new file mode 100644 index 000000000..c33157f93 --- /dev/null +++ b/src/Fallout.NuGet.Analysis/ProjectAssetsReader.cs @@ -0,0 +1,226 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using NuGet.Frameworks; + +namespace Fallout.NuGet.Analysis; + +/// +/// Reads a project.assets.json (the post-restore lock file) into one +/// per target framework. The assets file already encodes everything the analyzer needs: the declared +/// direct references (with autoReferenced / suppressParent), and the fully-resolved +/// transitive graph with the versions NuGet settled on. +/// +public static class ProjectAssetsReader +{ + /// + /// Locate the assets file for a project (the default <projDir>/obj/project.assets.json). + /// Returns null if it does not exist (i.e. the project has not been restored). + /// + public static string FindAssetsFile(string projectFilePath) + { + var dir = Path.GetDirectoryName(Path.GetFullPath(projectFilePath)); + if (dir == null) + return null; + + var assets = Path.Combine(dir, "obj", "project.assets.json"); + return File.Exists(assets) ? assets : null; + } + + /// Read every target framework out of the given assets file. + public static IReadOnlyList Read(string assetsFilePath) + { + using var document = JsonDocument.Parse(File.ReadAllText(assetsFilePath)); + var root = document.RootElement; + + var projectElement = root.TryGetProperty("project", out var p) ? p : default; + var restore = projectElement.ValueKind == JsonValueKind.Object && projectElement.TryGetProperty("restore", out var r) + ? r + : default; + + var projectPath = restore.ValueKind == JsonValueKind.Object && restore.TryGetProperty("projectPath", out var pp) + ? pp.GetString() + : assetsFilePath; + var projectName = restore.ValueKind == JsonValueKind.Object && restore.TryGetProperty("projectName", out var pn) + ? pn.GetString() + : Path.GetFileNameWithoutExtension(projectPath); + + // Build a graph per resolved target (skipping RID-specific "tfm/rid" targets). + var graphsByFramework = ReadGraphs(root); + + var results = new List(); + if (projectElement.ValueKind != JsonValueKind.Object || + !projectElement.TryGetProperty("frameworks", out var frameworks) || + frameworks.ValueKind != JsonValueKind.Object) + { + return results; + } + + foreach (var framework in frameworks.EnumerateObject()) + { + var shortTfm = ResolveShortTfm(framework); + var nugetFramework = TryParseFramework(framework.Name); + var graph = MatchGraph(graphsByFramework, nugetFramework); + if (graph == null) + continue; + + var directPackages = ReadDirectPackages(framework.Value, graph); + var directProjectNodeKeys = ReadDirectProjectNodeKeys(restore, framework.Name, graph); + + results.Add(new AnalyzedProject( + projectName, + projectPath, + shortTfm, + directPackages, + directProjectNodeKeys, + graph)); + } + + return results; + } + + private static Dictionary> ReadGraphs(JsonElement root) + { + var result = new Dictionary>(); + if (!root.TryGetProperty("targets", out var targets) || targets.ValueKind != JsonValueKind.Object) + return result; + + foreach (var target in targets.EnumerateObject()) + { + // Skip RID-qualified targets such as ".NETCoreApp,Version=v10.0/win-x64". + if (target.Name.Contains('/')) + continue; + + var framework = TryParseFramework(target.Name); + if (framework == null) + continue; + + var nodes = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var node in target.Value.EnumerateObject()) + { + var slash = node.Name.IndexOf('/'); + var name = slash >= 0 ? node.Name.Substring(0, slash) : node.Name; + var version = slash >= 0 ? node.Name.Substring(slash + 1) : string.Empty; + var type = node.Value.TryGetProperty("type", out var t) ? t.GetString() : "package"; + + var edges = new List(); + if (node.Value.TryGetProperty("dependencies", out var deps) && deps.ValueKind == JsonValueKind.Object) + { + foreach (var dep in deps.EnumerateObject()) + edges.Add(new Edge(dep.Name, dep.Value.GetString())); + } + + nodes[node.Name] = new GraphNode(node.Name, name, version, type, edges); + } + + result[framework] = nodes; + } + + return result; + } + + private static IReadOnlyDictionary MatchGraph( + Dictionary> graphs, + NuGetFramework framework) + { + if (framework != null && graphs.TryGetValue(framework, out var exact)) + return exact; + + // Single-target projects: pair the lone framework even if monikers parsed differently. + return graphs.Count == 1 ? graphs.Values.First() : null; + } + + private static IReadOnlyList ReadDirectPackages( + JsonElement framework, + IReadOnlyDictionary graph) + { + var result = new List(); + if (!framework.TryGetProperty("dependencies", out var deps) || deps.ValueKind != JsonValueKind.Object) + return result; + + foreach (var dep in deps.EnumerateObject()) + { + var value = dep.Value; + var target = value.TryGetProperty("target", out var tg) ? tg.GetString() : "Package"; + if (!string.Equals(target, "Package", StringComparison.OrdinalIgnoreCase)) + continue; + + var versionRange = value.TryGetProperty("version", out var v) ? v.GetString() : null; + var autoReferenced = value.TryGetProperty("autoReferenced", out var a) && + a.ValueKind == JsonValueKind.True; + var suppressParent = value.TryGetProperty("suppressParent", out var sp) ? sp.GetString() : null; + var privateAssetsAll = string.Equals(suppressParent, "All", StringComparison.OrdinalIgnoreCase); + + var nodeKey = FindNodeKeyByName(graph, dep.Name); + result.Add(new DirectDependency(dep.Name, versionRange, autoReferenced, privateAssetsAll, nodeKey)); + } + + return result; + } + + private static IReadOnlyList ReadDirectProjectNodeKeys( + JsonElement restore, + string frameworkName, + IReadOnlyDictionary graph) + { + var result = new List(); + if (restore.ValueKind != JsonValueKind.Object || + !restore.TryGetProperty("frameworks", out var frameworks) || + frameworks.ValueKind != JsonValueKind.Object || + !frameworks.TryGetProperty(frameworkName, out var framework) || + !framework.TryGetProperty("projectReferences", out var projectReferences) || + projectReferences.ValueKind != JsonValueKind.Object) + { + return result; + } + + foreach (var reference in projectReferences.EnumerateObject()) + { + var projectFilePath = reference.Value.TryGetProperty("projectPath", out var pp) + ? pp.GetString() + : reference.Name; + var name = Path.GetFileNameWithoutExtension(projectFilePath); + + var node = graph.Values.FirstOrDefault(x => + x.IsProject && string.Equals(x.Name, name, StringComparison.OrdinalIgnoreCase)); + if (node != null) + result.Add(node.Key); + } + + return result; + } + + private static string FindNodeKeyByName(IReadOnlyDictionary graph, string name) + { + return graph.Values + .FirstOrDefault(x => string.Equals(x.Name, name, StringComparison.OrdinalIgnoreCase)) + ?.Key; + } + + private static string ResolveShortTfm(JsonProperty framework) + { + if (framework.Value.TryGetProperty("targetAlias", out var alias)) + { + var value = alias.GetString(); + if (!string.IsNullOrEmpty(value)) + return value; + } + + return framework.Name; + } + + private static NuGetFramework TryParseFramework(string moniker) + { + try + { + var framework = NuGetFramework.Parse(moniker); + return framework.IsUnsupported ? null : framework; + } + catch (Exception) + { + return null; + } + } +} diff --git a/tests/Fallout.NuGet.Analysis.Tests/Fallout.NuGet.Analysis.Tests.csproj b/tests/Fallout.NuGet.Analysis.Tests/Fallout.NuGet.Analysis.Tests.csproj new file mode 100644 index 000000000..39131917d --- /dev/null +++ b/tests/Fallout.NuGet.Analysis.Tests/Fallout.NuGet.Analysis.Tests.csproj @@ -0,0 +1,15 @@ + + + + net10.0 + + + + + + + + + + + diff --git a/tests/Fallout.NuGet.Analysis.Tests/PackageAnalyzerTests.cs b/tests/Fallout.NuGet.Analysis.Tests/PackageAnalyzerTests.cs new file mode 100644 index 000000000..7336ba08f --- /dev/null +++ b/tests/Fallout.NuGet.Analysis.Tests/PackageAnalyzerTests.cs @@ -0,0 +1,197 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using FluentAssertions; +using Xunit; + +namespace Fallout.NuGet.Analysis.Tests; + +public sealed class PackageAnalyzerTests +{ + [Fact] + public void Detects_transitive_package_redundancy_and_marks_it_safe() + { + // Direct refs A and B; A depends on B at the same version B resolves to. + var assets = WriteAssets(Scenario( + directDependencies: """ + "A": { "target": "Package", "version": "[1.0.0, )" }, + "B": { "target": "Package", "version": "[1.0.0, )" } + """, + target: """ + "A/1.0.0": { "type": "package", "dependencies": { "B": "1.0.0" } }, + "B/1.0.0": { "type": "package" } + """)); + + var findings = Analyze(assets); + + var finding = findings.Single(x => x.PackageId == "B"); + finding.Kind.Should().Be(FindingKind.RedundantViaPackage); + finding.Providers.Should().ContainSingle().Which.Should().Be("A"); + finding.SafeToRemove.Should().BeTrue(); + } + + [Fact] + public void Flags_might_downgrade_when_the_direct_reference_pins_higher_than_the_transitive_one() + { + // B is pinned directly at 2.0.0 (so it resolves to 2.0.0) but A only asks for 1.0.0. + var assets = WriteAssets(Scenario( + directDependencies: """ + "A": { "target": "Package", "version": "[1.0.0, )" }, + "B": { "target": "Package", "version": "[2.0.0, )" } + """, + target: """ + "A/1.0.0": { "type": "package", "dependencies": { "B": "1.0.0" } }, + "B/2.0.0": { "type": "package" } + """)); + + var finding = Analyze(assets).Single(x => x.PackageId == "B"); + + finding.SafeToRemove.Should().BeFalse(); + finding.ResolvedVersion.Should().Be("2.0.0"); + finding.Detail.Should().Contain("downgrade"); + } + + [Fact] + public void Ignores_auto_referenced_and_private_assets_dependencies() + { + var assets = WriteAssets(Scenario( + directDependencies: """ + "A": { "target": "Package", "version": "[1.0.0, )" }, + "B": { "target": "Package", "version": "[1.0.0, )", "autoReferenced": true }, + "C": { "target": "Package", "version": "[1.0.0, )", "suppressParent": "All" } + """, + target: """ + "A/1.0.0": { "type": "package", "dependencies": { "B": "1.0.0", "C": "1.0.0" } }, + "B/1.0.0": { "type": "package" }, + "C/1.0.0": { "type": "package" } + """)); + + Analyze(assets).Should().BeEmpty(); + } + + [Fact] + public void Respects_the_exclude_option() + { + var assets = WriteAssets(Scenario( + directDependencies: """ + "A": { "target": "Package", "version": "[1.0.0, )" }, + "B": { "target": "Package", "version": "[1.0.0, )" } + """, + target: """ + "A/1.0.0": { "type": "package", "dependencies": { "B": "1.0.0" } }, + "B/1.0.0": { "type": "package" } + """)); + + var options = new AnalyzerOptions(); + options.ExcludedPackageIds.Add("B"); + + Analyze(assets, options).Should().BeEmpty(); + } + + [Fact] + public void Detects_redundancy_provided_through_a_project_reference() + { + var assets = WriteAssets(Scenario( + directDependencies: """ + "Newtonsoft.Json": { "target": "Package", "version": "[13.0.0, )" } + """, + target: """ + "MyLib/1.0.0": { "type": "project", "dependencies": { "Newtonsoft.Json": "13.0.0" } }, + "Newtonsoft.Json/13.0.0": { "type": "package" } + """, + projectReferences: """ + "/repo/MyLib/MyLib.csproj": { "projectPath": "/repo/MyLib/MyLib.csproj" } + """)); + + var finding = Analyze(assets).Single(); + + finding.Kind.Should().Be(FindingKind.RedundantViaProject); + finding.PackageId.Should().Be("Newtonsoft.Json"); + finding.Providers.Should().Contain("MyLib"); + } + + [Fact] + public void Detects_version_conflicts_across_projects() + { + var projectOne = ProjectAssetsReader.Read(WriteAssets(Scenario( + projectName: "ProjectOne", + directDependencies: """ "Serilog": { "target": "Package", "version": "[3.0.0, )" } """, + target: """ "Serilog/3.0.0": { "type": "package" } """))); + + var projectTwo = ProjectAssetsReader.Read(WriteAssets(Scenario( + projectName: "ProjectTwo", + directDependencies: """ "Serilog": { "target": "Package", "version": "[4.0.0, )" } """, + target: """ "Serilog/4.0.0": { "type": "package" } """))); + + var conflicts = new PackageAnalyzer() + .Analyze(projectOne.Concat(projectTwo).ToList()) + .Where(x => x.Kind == FindingKind.VersionConflict) + .ToList(); + + var serilog = conflicts.Single(x => x.PackageId == "Serilog"); + serilog.Providers.Should().BeEquivalentTo(new[] { "3.0.0", "4.0.0" }); + } + + private static IReadOnlyList Analyze(string assetsFile, AnalyzerOptions options = null) + { + var projects = ProjectAssetsReader.Read(assetsFile); + return new PackageAnalyzer().Analyze(projects, options) + .Where(x => x.Kind != FindingKind.VersionConflict) + .ToList(); + } + + private static string Scenario( + string directDependencies, + string target, + string projectReferences = null, + string projectName = "TestProject", + string tfm = "net10.0") + { + var projectReferencesJson = projectReferences == null + ? string.Empty + : $$"""" + , + "projectReferences": { + {{projectReferences}} + } + """"; + + return $$""" + { + "version": 3, + "targets": { + "net10.0": { + {{target}} + } + }, + "project": { + "version": "1.0.0", + "restore": { + "projectName": "{{projectName}}", + "projectPath": "/repo/{{projectName}}/{{projectName}}.csproj", + "frameworks": { + "{{tfm}}": { + "targetAlias": "{{tfm}}"{{projectReferencesJson}} + } + } + }, + "frameworks": { + "{{tfm}}": { + "targetAlias": "{{tfm}}", + "dependencies": { + {{directDependencies}} + } + } + } + } + } + """; + } + + private static string WriteAssets(string json) + { + var path = Path.Combine(Path.GetTempPath(), $"assets-{System.Guid.NewGuid():N}.json"); + File.WriteAllText(path, json); + return path; + } +} From fc358b8011ff1bfd204d78e584f966d8fc91cab2 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Thu, 25 Jun 2026 13:14:40 +1200 Subject: [PATCH 2/4] Parse .sln/.slnx via the solution model and add a Spectre table view Targeting a solution now reads real project membership through Fallout.Solutions.ReadSolution() instead of globbing the directory. Adds a `--format table|flat` option (table is the default) rendering findings as Spectre tables; flat keeps the log-line output. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Fallout.Cli/Program.Analyze.cs | 131 ++++++++++++++++++++++++++--- 1 file changed, 121 insertions(+), 10 deletions(-) diff --git a/src/Fallout.Cli/Program.Analyze.cs b/src/Fallout.Cli/Program.Analyze.cs index bf6608fab..beb3ebbf2 100644 --- a/src/Fallout.Cli/Program.Analyze.cs +++ b/src/Fallout.Cli/Program.Analyze.cs @@ -6,6 +6,8 @@ using Fallout.Common.Execution; using Fallout.Common.IO; using Fallout.NuGet.Analysis; +using Fallout.Solutions; +using Spectre.Console; namespace Fallout.Cli; @@ -19,11 +21,11 @@ public static int Analyze(string[] args, AbsolutePath rootDirectory, AbsolutePat var subCommand = args.ElementAtOrDefault(0); if (!string.Equals(subCommand, "packages", StringComparison.OrdinalIgnoreCase)) { - Host.Error("Usage: fallout :analyze packages [] [--tfm ] [--severity none|trace|normal|warning|error] [--exclude [,...]]"); + Host.Error("Usage: fallout :analyze packages [] [--tfm ] [--severity none|trace|normal|warning|error] [--format table|flat] [--exclude [,...]]"); return 1; } - if (!TryParseAnalyzeArguments(args.Skip(1).ToArray(), out var path, out var tfm, out var severity, out var excludes)) + if (!TryParseAnalyzeArguments(args.Skip(1).ToArray(), out var path, out var tfm, out var severity, out var format, out var excludes)) return 1; var projectFiles = ResolveProjectFiles(path); @@ -66,22 +68,33 @@ public static int Analyze(string[] args, AbsolutePath rootDirectory, AbsolutePat var findings = new PackageAnalyzer().Analyze(analyzed, options); - Report(findings, severity, analyzed.Count, restoreMissing); + if (format == OutputFormat.Table) + RenderTables(findings, analyzed.Count, restoreMissing); + else + RenderFlat(findings, severity, analyzed.Count, restoreMissing); var failing = severity == LogLevel.Error && findings.Count > 0; return failing ? 1 : 0; } + private enum OutputFormat + { + Table, + Flat, + } + private static bool TryParseAnalyzeArguments( string[] args, out string path, out string tfm, out LogLevel severity, + out OutputFormat format, out HashSet excludes) { path = null; tfm = null; severity = LogLevel.Warning; + format = OutputFormat.Table; excludes = new HashSet(StringComparer.OrdinalIgnoreCase); for (var i = 0; i < args.Length; i++) @@ -97,6 +110,10 @@ private static bool TryParseAnalyzeArguments( if (++i >= args.Length) { Host.Error("--severity requires a value."); return false; } if (!TryParseSeverity(args[i], out severity)) { Host.Error($"Unknown severity '{args[i]}'."); return false; } break; + case "--format": + if (++i >= args.Length) { Host.Error("--format requires a value."); return false; } + if (!TryParseFormat(args[i], out format)) { Host.Error($"Unknown format '{args[i]}' (use table|flat)."); return false; } + break; case "--exclude": if (++i >= args.Length) { Host.Error("--exclude requires a value."); return false; } foreach (var id in args[i].Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) @@ -130,6 +147,16 @@ private static bool TryParseSeverity(string value, out LogLevel severity) } } + private static bool TryParseFormat(string value, out OutputFormat format) + { + switch (value.ToLowerInvariant()) + { + case "table": format = OutputFormat.Table; return true; + case "flat": case "lines": format = OutputFormat.Flat; return true; + default: format = OutputFormat.Table; return false; + } + } + private static List ResolveProjectFiles(string path) { if (string.IsNullOrEmpty(path)) @@ -137,20 +164,37 @@ private static List ResolveProjectFiles(string path) var full = Path.GetFullPath(path); - if (File.Exists(full) && full.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase)) - return new List { full }; + if (File.Exists(full)) + { + if (full.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase)) + return new List { full }; - if (Directory.Exists(full)) - return GlobProjects(full); + if (full.EndsWith(".sln", StringComparison.OrdinalIgnoreCase) || + full.EndsWith(".slnx", StringComparison.OrdinalIgnoreCase)) + return ReadSolutionProjects(full); - // .sln / .slnx (or any file): analyze the projects under its directory. - if (File.Exists(full)) + // Some other file — fall back to globbing its directory. return GlobProjects(Path.GetDirectoryName(full)); + } + + if (Directory.Exists(full)) + return GlobProjects(full); Host.Error($"Path not found: {path}"); return null; } + private static List ReadSolutionProjects(string solutionFile) + { + var solution = ((AbsolutePath)solutionFile).ReadSolution(); + return solution.AllProjects + .Select(x => (string)x.Path) + .Where(x => x.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase)) + .Where(File.Exists) + .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + private static List GlobProjects(string directory) { return Directory.GetFiles(directory, "*.csproj", SearchOption.AllDirectories) @@ -165,7 +209,74 @@ private static bool ContainsSegment(string filePath, string segment) filePath.Contains($"{Path.AltDirectorySeparatorChar}{segment}{Path.AltDirectorySeparatorChar}"); } - private static void Report(IReadOnlyList findings, LogLevel severity, int projectCount, int restoreMissing) + private static void RenderTables(IReadOnlyList findings, int projectCount, int restoreMissing) + { + var redundant = findings.Where(x => x.Kind != FindingKind.VersionConflict) + .OrderBy(x => x.Project).ThenBy(x => x.PackageId).ToList(); + var conflicts = findings.Where(x => x.Kind == FindingKind.VersionConflict) + .OrderBy(x => x.PackageId).ToList(); + + if (findings.Count == 0) + { + AnsiConsole.MarkupLine($"[green]✓ No redundant or conflicting package references across {projectCount} target(s).[/]"); + if (restoreMissing > 0) + AnsiConsole.MarkupLine($"[grey]{restoreMissing} project(s) skipped — not restored.[/]"); + return; + } + + if (redundant.Count > 0) + { + var table = new Table { Border = TableBorder.Rounded, Title = new TableTitle("[bold]Redundant package references[/]") }; + table.AddColumn("Project"); + table.AddColumn("TFM"); + table.AddColumn("Package"); + table.AddColumn("Version"); + table.AddColumn("Action"); + table.AddColumn("Provided by"); + + foreach (var finding in redundant) + { + var action = finding.SafeToRemove ? "[green]remove[/]" : "[yellow]review[/]"; + var via = finding.Kind == FindingKind.RedundantViaProject ? "[blue]proj[/]" : "[grey]pkg[/]"; + table.AddRow( + Markup.Escape(finding.Project ?? string.Empty), + Markup.Escape(finding.TargetFramework ?? string.Empty), + Markup.Escape(finding.PackageId ?? string.Empty), + Markup.Escape(finding.ResolvedVersion ?? string.Empty), + action, + $"{via} {Markup.Escape(string.Join(", ", finding.Providers))}"); + } + + AnsiConsole.Write(table); + } + + if (conflicts.Count > 0) + { + var table = new Table { Border = TableBorder.Rounded, Title = new TableTitle("[bold]Version conflicts[/]") }; + table.AddColumn("Package"); + table.AddColumn("Resolved versions (projects)"); + + foreach (var finding in conflicts) + table.AddRow(Markup.Escape(finding.PackageId ?? string.Empty), Markup.Escape(ConflictBreakdown(finding))); + + AnsiConsole.Write(table); + } + + AnsiConsole.MarkupLine( + $"[bold]Summary:[/] [yellow]{redundant.Count}[/] redundant, [yellow]{conflicts.Count}[/] conflict(s) across {projectCount} target(s)." + + (restoreMissing > 0 ? $" [grey]({restoreMissing} skipped — not restored)[/]" : string.Empty)); + } + + private static string ConflictBreakdown(Finding finding) + { + // Finding.Detail looks like " resolves to multiple versions: 3.0.0 (A, B); 4.0.0 (C)." + var detail = finding.Detail ?? string.Empty; + var marker = detail.IndexOf(": ", StringComparison.Ordinal); + var body = marker >= 0 ? detail.Substring(marker + 2).TrimEnd('.') : detail; + return string.Join("\n", body.Split("; ")); + } + + private static void RenderFlat(IReadOnlyList findings, LogLevel severity, int projectCount, int restoreMissing) { void Emit(string text) { From 52e2210f64a45a69c1d3d04de705f878c254228a Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Thu, 25 Jun 2026 13:21:49 +1200 Subject: [PATCH 3/4] Group redundant findings by project, detect conflicts per-TFM, gate conflicts behind --conflicts Table view now renders redundant references as a per-project tree (the actionable cleanup list) and hides version conflicts unless --conflicts is passed (then a compact counts view, or --verbose for the project lists). Version-conflict detection is now done per target framework, so a multi-targeted project that legitimately pins different versions across its own frameworks is no longer reported as a conflict. Conflict data is exposed structurally via Finding.ConflictVersions. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Fallout.Cli/Program.Analyze.cs | 125 +++++++++++------- src/Fallout.NuGet.Analysis/Models.cs | 17 +++ src/Fallout.NuGet.Analysis/PackageAnalyzer.cs | 81 ++++++++---- 3 files changed, 147 insertions(+), 76 deletions(-) diff --git a/src/Fallout.Cli/Program.Analyze.cs b/src/Fallout.Cli/Program.Analyze.cs index beb3ebbf2..80c9350c2 100644 --- a/src/Fallout.Cli/Program.Analyze.cs +++ b/src/Fallout.Cli/Program.Analyze.cs @@ -21,11 +21,11 @@ public static int Analyze(string[] args, AbsolutePath rootDirectory, AbsolutePat var subCommand = args.ElementAtOrDefault(0); if (!string.Equals(subCommand, "packages", StringComparison.OrdinalIgnoreCase)) { - Host.Error("Usage: fallout :analyze packages [] [--tfm ] [--severity none|trace|normal|warning|error] [--format table|flat] [--exclude [,...]]"); + Host.Error("Usage: fallout :analyze packages [] [--tfm ] [--severity none|trace|normal|warning|error] [--format table|flat] [--conflicts] [--verbose] [--exclude [,...]]"); return 1; } - if (!TryParseAnalyzeArguments(args.Skip(1).ToArray(), out var path, out var tfm, out var severity, out var format, out var excludes)) + if (!TryParseAnalyzeArguments(args.Skip(1).ToArray(), out var path, out var tfm, out var severity, out var format, out var showConflicts, out var verbose, out var excludes)) return 1; var projectFiles = ResolveProjectFiles(path); @@ -69,9 +69,9 @@ public static int Analyze(string[] args, AbsolutePath rootDirectory, AbsolutePat var findings = new PackageAnalyzer().Analyze(analyzed, options); if (format == OutputFormat.Table) - RenderTables(findings, analyzed.Count, restoreMissing); + RenderTables(findings, analyzed.Count, restoreMissing, showConflicts, verbose); else - RenderFlat(findings, severity, analyzed.Count, restoreMissing); + RenderFlat(findings, severity, analyzed.Count, restoreMissing, showConflicts); var failing = severity == LogLevel.Error && findings.Count > 0; return failing ? 1 : 0; @@ -89,12 +89,16 @@ private static bool TryParseAnalyzeArguments( out string tfm, out LogLevel severity, out OutputFormat format, + out bool showConflicts, + out bool verbose, out HashSet excludes) { path = null; tfm = null; severity = LogLevel.Warning; format = OutputFormat.Table; + showConflicts = false; + verbose = false; excludes = new HashSet(StringComparer.OrdinalIgnoreCase); for (var i = 0; i < args.Length; i++) @@ -114,6 +118,13 @@ private static bool TryParseAnalyzeArguments( if (++i >= args.Length) { Host.Error("--format requires a value."); return false; } if (!TryParseFormat(args[i], out format)) { Host.Error($"Unknown format '{args[i]}' (use table|flat)."); return false; } break; + case "--conflicts": + showConflicts = true; + break; + case "--verbose": + case "-v": + verbose = true; + break; case "--exclude": if (++i >= args.Length) { Host.Error("--exclude requires a value."); return false; } foreach (var id in args[i].Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) @@ -209,12 +220,11 @@ private static bool ContainsSegment(string filePath, string segment) filePath.Contains($"{Path.AltDirectorySeparatorChar}{segment}{Path.AltDirectorySeparatorChar}"); } - private static void RenderTables(IReadOnlyList findings, int projectCount, int restoreMissing) + private static void RenderTables(IReadOnlyList findings, int projectCount, int restoreMissing, bool showConflicts, bool verbose) { - var redundant = findings.Where(x => x.Kind != FindingKind.VersionConflict) - .OrderBy(x => x.Project).ThenBy(x => x.PackageId).ToList(); + var redundant = findings.Where(x => x.Kind != FindingKind.VersionConflict).ToList(); var conflicts = findings.Where(x => x.Kind == FindingKind.VersionConflict) - .OrderBy(x => x.PackageId).ToList(); + .OrderBy(x => x.PackageId, StringComparer.OrdinalIgnoreCase).ToList(); if (findings.Count == 0) { @@ -225,58 +235,67 @@ private static void RenderTables(IReadOnlyList findings, int projectCou } if (redundant.Count > 0) + RenderRedundantTree(redundant); + + if (showConflicts && conflicts.Count > 0) + RenderConflictsTable(conflicts, verbose); + + var conflictHint = !showConflicts && conflicts.Count > 0 + ? " [grey](run with --conflicts to list them)[/]" + : string.Empty; + AnsiConsole.MarkupLine( + $"[bold]Summary:[/] [yellow]{redundant.Count}[/] redundant reference(s), [yellow]{conflicts.Count}[/] version conflict(s) across {projectCount} target(s)." + + conflictHint + + (restoreMissing > 0 ? $" [grey]({restoreMissing} skipped — not restored)[/]" : string.Empty)); + } + + private static void RenderRedundantTree(IReadOnlyList redundant) + { + var tree = new Tree("[bold]Redundant package references[/]"); + + var groups = redundant + .GroupBy(x => (Project: x.Project ?? string.Empty, Tfm: x.TargetFramework ?? string.Empty)) + .OrderBy(x => x.Key.Project, StringComparer.OrdinalIgnoreCase) + .ThenBy(x => x.Key.Tfm, StringComparer.OrdinalIgnoreCase); + + foreach (var group in groups) { - var table = new Table { Border = TableBorder.Rounded, Title = new TableTitle("[bold]Redundant package references[/]") }; - table.AddColumn("Project"); - table.AddColumn("TFM"); - table.AddColumn("Package"); - table.AddColumn("Version"); - table.AddColumn("Action"); - table.AddColumn("Provided by"); - - foreach (var finding in redundant) + var node = tree.AddNode($"[bold]{Markup.Escape(group.Key.Project)}[/] [grey]({Markup.Escape(group.Key.Tfm)})[/]"); + + var width = group.Max(x => (x.PackageId ?? string.Empty).Length); + foreach (var finding in group.OrderBy(x => x.PackageId, StringComparer.OrdinalIgnoreCase)) { var action = finding.SafeToRemove ? "[green]remove[/]" : "[yellow]review[/]"; - var via = finding.Kind == FindingKind.RedundantViaProject ? "[blue]proj[/]" : "[grey]pkg[/]"; - table.AddRow( - Markup.Escape(finding.Project ?? string.Empty), - Markup.Escape(finding.TargetFramework ?? string.Empty), - Markup.Escape(finding.PackageId ?? string.Empty), - Markup.Escape(finding.ResolvedVersion ?? string.Empty), - action, - $"{via} {Markup.Escape(string.Join(", ", finding.Providers))}"); + var via = finding.Kind == FindingKind.RedundantViaProject ? "[blue]proj[/]" : "[grey]pkg [/]"; + var package = Markup.Escape((finding.PackageId ?? string.Empty).PadRight(width)); + var version = Markup.Escape(finding.ResolvedVersion ?? string.Empty); + var providers = Markup.Escape(string.Join(", ", finding.Providers)); + node.AddNode($"{action} {package} [grey]{version}[/] [grey]←[/] {via} {providers}"); } - - AnsiConsole.Write(table); } - if (conflicts.Count > 0) - { - var table = new Table { Border = TableBorder.Rounded, Title = new TableTitle("[bold]Version conflicts[/]") }; - table.AddColumn("Package"); - table.AddColumn("Resolved versions (projects)"); + AnsiConsole.Write(tree); + } - foreach (var finding in conflicts) - table.AddRow(Markup.Escape(finding.PackageId ?? string.Empty), Markup.Escape(ConflictBreakdown(finding))); + private static void RenderConflictsTable(IReadOnlyList conflicts, bool verbose) + { + var table = new Table { Border = TableBorder.Rounded, Title = new TableTitle("[bold]Version conflicts[/]") }; + table.AddColumn("Package"); + table.AddColumn(verbose ? "Resolved versions (projects)" : "Resolved versions"); - AnsiConsole.Write(table); - } + foreach (var finding in conflicts) + { + var cell = verbose + ? string.Join("\n", finding.ConflictVersions.Select(v => $"{v.Version} ({string.Join(", ", v.Projects)})")) + : string.Join(" · ", finding.ConflictVersions.Select(v => $"{v.Version} ×{v.Projects.Count}")); - AnsiConsole.MarkupLine( - $"[bold]Summary:[/] [yellow]{redundant.Count}[/] redundant, [yellow]{conflicts.Count}[/] conflict(s) across {projectCount} target(s)." + - (restoreMissing > 0 ? $" [grey]({restoreMissing} skipped — not restored)[/]" : string.Empty)); - } + table.AddRow(Markup.Escape(finding.PackageId ?? string.Empty), Markup.Escape(cell)); + } - private static string ConflictBreakdown(Finding finding) - { - // Finding.Detail looks like " resolves to multiple versions: 3.0.0 (A, B); 4.0.0 (C)." - var detail = finding.Detail ?? string.Empty; - var marker = detail.IndexOf(": ", StringComparison.Ordinal); - var body = marker >= 0 ? detail.Substring(marker + 2).TrimEnd('.') : detail; - return string.Join("\n", body.Split("; ")); + AnsiConsole.Write(table); } - private static void RenderFlat(IReadOnlyList findings, LogLevel severity, int projectCount, int restoreMissing) + private static void RenderFlat(IReadOnlyList findings, LogLevel severity, int projectCount, int restoreMissing, bool showConflicts) { void Emit(string text) { @@ -307,11 +326,15 @@ void Emit(string text) Emit($"[{finding.Project} ({finding.TargetFramework})] {finding.PackageId} {finding.ResolvedVersion} — {marker}. {finding.Detail}"); } - foreach (var finding in conflicts.OrderBy(x => x.PackageId)) - Emit($"[version conflict] {finding.Detail}"); + if (showConflicts) + { + foreach (var finding in conflicts.OrderBy(x => x.PackageId)) + Emit($"[version conflict] {finding.Detail}"); + } Host.Information( - $"Summary: {redundant.Count} redundant reference(s), {conflicts.Count} version conflict(s) across {projectCount} target(s)."); + $"Summary: {redundant.Count} redundant reference(s), {conflicts.Count} version conflict(s) across {projectCount} target(s)." + + (!showConflicts && conflicts.Count > 0 ? " (run with --conflicts to list them)" : string.Empty)); if (restoreMissing > 0) Host.Information($"({restoreMissing} project(s) skipped — not restored.)"); } diff --git a/src/Fallout.NuGet.Analysis/Models.cs b/src/Fallout.NuGet.Analysis/Models.cs index c9b0b7d9f..f49d5a4e9 100644 --- a/src/Fallout.NuGet.Analysis/Models.cs +++ b/src/Fallout.NuGet.Analysis/Models.cs @@ -158,6 +158,23 @@ public sealed class Finding /// The projects/packages that already provide this package. public IReadOnlyList Providers { get; set; } = new List(); + /// For : each resolved version and the projects on it. + public IReadOnlyList ConflictVersions { get; set; } = new List(); + /// A human-readable explanation. public string Detail { get; set; } } + +/// One resolved version of a package and the projects that landed on it. +public sealed class ConflictVersion +{ + public ConflictVersion(string version, IReadOnlyList projects) + { + Version = version; + Projects = projects; + } + + public string Version { get; } + + public IReadOnlyList Projects { get; } +} diff --git a/src/Fallout.NuGet.Analysis/PackageAnalyzer.cs b/src/Fallout.NuGet.Analysis/PackageAnalyzer.cs index de2727087..f777308db 100644 --- a/src/Fallout.NuGet.Analysis/PackageAnalyzer.cs +++ b/src/Fallout.NuGet.Analysis/PackageAnalyzer.cs @@ -109,53 +109,84 @@ public IReadOnlyList AnalyzeRedundancy(AnalyzedProject project, Analyze return findings; } - /// Same package resolved at different versions across the analyzed projects. + /// + /// Same package resolved at different versions across the analyzed projects. + /// Detection is done per target framework, so a multi-targeted project that legitimately + /// pins different versions across its own frameworks is not reported as a conflict. + /// public IReadOnlyList FindVersionConflicts(IReadOnlyList projects, AnalyzerOptions options = null) { options ??= new AnalyzerOptions(); - var findings = new List(); - // package id -> (project, version) occurrences across all graphs. - var occurrences = new Dictionary>(StringComparer.OrdinalIgnoreCase); - foreach (var project in projects) + // package -> version -> projects, accumulated only from frameworks where the package actually conflicts. + var conflicting = new Dictionary>>(StringComparer.OrdinalIgnoreCase); + + foreach (var tfmGroup in projects.GroupBy(x => x.TargetFramework, StringComparer.OrdinalIgnoreCase)) { - foreach (var node in project.Graph.Values) + var perPackage = new Dictionary>>(StringComparer.OrdinalIgnoreCase); + foreach (var project in tfmGroup) { - if (node.IsProject || string.IsNullOrEmpty(node.Version)) - continue; - if (options.ExcludedPackageIds.Contains(node.Name)) - continue; + foreach (var node in project.Graph.Values) + { + if (node.IsProject || string.IsNullOrEmpty(node.Version)) + continue; + if (options.ExcludedPackageIds.Contains(node.Name)) + continue; + + if (!perPackage.TryGetValue(node.Name, out var byVersion)) + perPackage[node.Name] = byVersion = new Dictionary>(StringComparer.OrdinalIgnoreCase); + if (!byVersion.TryGetValue(node.Version, out var projectsOnVersion)) + byVersion[node.Version] = projectsOnVersion = new SortedSet(StringComparer.OrdinalIgnoreCase); + projectsOnVersion.Add(project.ProjectName); + } + } + + foreach (var pair in perPackage) + { + if (pair.Value.Count < 2) + continue; // one resolved version within this framework — not a conflict - if (!occurrences.TryGetValue(node.Name, out var list)) - occurrences[node.Name] = list = new List<(string, string)>(); - list.Add((project.ProjectName, node.Version)); + if (!conflicting.TryGetValue(pair.Key, out var merged)) + conflicting[pair.Key] = merged = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var version in pair.Value) + { + if (!merged.TryGetValue(version.Key, out var projectsOnVersion)) + merged[version.Key] = projectsOnVersion = new SortedSet(StringComparer.OrdinalIgnoreCase); + foreach (var project in version.Value) + projectsOnVersion.Add(project); + } } } - foreach (var pair in occurrences) + var findings = new List(); + foreach (var pair in conflicting) { - var distinctVersions = pair.Value.Select(x => x.Version).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); - if (distinctVersions.Count < 2) - continue; - - var breakdown = pair.Value - .GroupBy(x => x.Version, StringComparer.OrdinalIgnoreCase) - .OrderByDescending(g => g.Key) - .Select(g => $"{g.Key} ({string.Join(", ", g.Select(x => x.Project).Distinct())})"); + var versions = pair.Value + .OrderByDescending(x => ParseVersionOrZero(x.Key)) + .Select(x => new ConflictVersion(x.Key, x.Value.ToList())) + .ToList(); findings.Add(new Finding { Kind = FindingKind.VersionConflict, PackageId = pair.Key, - ResolvedVersion = distinctVersions.OrderByDescending(x => x).First(), - Providers = distinctVersions, - Detail = $"{pair.Key} resolves to multiple versions: {string.Join("; ", breakdown)}.", + ResolvedVersion = versions.First().Version, + Providers = versions.Select(x => x.Version).ToList(), + ConflictVersions = versions, + Detail = $"{pair.Key} resolves to multiple versions: " + + string.Join("; ", versions.Select(v => $"{v.Version} ({string.Join(", ", v.Projects)})")) + ".", }); } return findings; } + private static NuGetVersion ParseVersionOrZero(string version) + { + return NuGetVersion.TryParse(version, out var parsed) ? parsed : new NuGetVersion(0, 0, 0); + } + private static bool IsSafeToRemove( IReadOnlyDictionary graph, DirectDependency direct, From 955f9bfbfcafd4cc572e20cef968c359e41cf1a8 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Thu, 25 Jun 2026 14:09:10 +1200 Subject: [PATCH 4/4] Update StronglyTypedSolutionGenerator snapshot for the new analyzer projects Adds Fallout.NuGet.Analysis + Fallout.NuGet.Analysis.Tests to the generated strongly-typed solution view (and picks up the pre-existing Components.Tests entry the snapshot was missing). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...ronglyTypedSolutionGeneratorTest.Test#Solution.g.verified.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/Fallout.SourceGenerators.Tests/StronglyTypedSolutionGeneratorTest.Test#Solution.g.verified.cs b/tests/Fallout.SourceGenerators.Tests/StronglyTypedSolutionGeneratorTest.Test#Solution.g.verified.cs index 2dec22605..946b43c24 100644 --- a/tests/Fallout.SourceGenerators.Tests/StronglyTypedSolutionGeneratorTest.Test#Solution.g.verified.cs +++ b/tests/Fallout.SourceGenerators.Tests/StronglyTypedSolutionGeneratorTest.Test#Solution.g.verified.cs @@ -27,6 +27,8 @@ internal class Solution(SolutionModel model, AbsolutePath path) : Fallout.Soluti public Fallout.Solutions.Project Fallout_Migrate_Analyzers_Tests => this.GetProject("Fallout.Migrate.Analyzers.Tests"); public Fallout.Solutions.Project Fallout_Migrate_Tests => this.GetProject("Fallout.Migrate.Tests"); public Fallout.Solutions.Project Fallout_MSBuildTasks => this.GetProject("Fallout.MSBuildTasks"); + public Fallout.Solutions.Project Fallout_NuGet_Analysis => this.GetProject("Fallout.NuGet.Analysis"); + public Fallout.Solutions.Project Fallout_NuGet_Analysis_Tests => this.GetProject("Fallout.NuGet.Analysis.Tests"); public Fallout.Solutions.Project Fallout_Persistence_Solution => this.GetProject("Fallout.Persistence.Solution"); public Fallout.Solutions.Project Fallout_Persistence_Solution_Benchmarks => this.GetProject("Fallout.Persistence.Solution.Benchmarks"); public Fallout.Solutions.Project Fallout_ProjectModel => this.GetProject("Fallout.ProjectModel");