diff --git a/fallout.slnx b/fallout.slnx
index 0f6ae969..6413e9fc 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 996a07f1..b76ee701 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 00000000..80c9350c
--- /dev/null
+++ b/src/Fallout.Cli/Program.Analyze.cs
@@ -0,0 +1,341 @@
+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;
+using Fallout.Solutions;
+using Spectre.Console;
+
+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] [--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 showConflicts, out var verbose, 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);
+
+ if (format == OutputFormat.Table)
+ RenderTables(findings, analyzed.Count, restoreMissing, showConflicts, verbose);
+ else
+ RenderFlat(findings, severity, analyzed.Count, restoreMissing, showConflicts);
+
+ 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 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++)
+ {
+ 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 "--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 "--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))
+ 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 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))
+ return GlobProjects(Directory.GetCurrentDirectory());
+
+ var full = Path.GetFullPath(path);
+
+ if (File.Exists(full))
+ {
+ if (full.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase))
+ return new List { full };
+
+ if (full.EndsWith(".sln", StringComparison.OrdinalIgnoreCase) ||
+ full.EndsWith(".slnx", StringComparison.OrdinalIgnoreCase))
+ return ReadSolutionProjects(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)
+ .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 RenderTables(IReadOnlyList findings, int projectCount, int restoreMissing, bool showConflicts, bool verbose)
+ {
+ var redundant = findings.Where(x => x.Kind != FindingKind.VersionConflict).ToList();
+ var conflicts = findings.Where(x => x.Kind == FindingKind.VersionConflict)
+ .OrderBy(x => x.PackageId, StringComparer.OrdinalIgnoreCase).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)
+ 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 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 [/]";
+ 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(tree);
+ }
+
+ 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");
+
+ 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}"));
+
+ table.AddRow(Markup.Escape(finding.PackageId ?? string.Empty), Markup.Escape(cell));
+ }
+
+ AnsiConsole.Write(table);
+ }
+
+ private static void RenderFlat(IReadOnlyList findings, LogLevel severity, int projectCount, int restoreMissing, bool showConflicts)
+ {
+ 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}");
+ }
+
+ 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)." +
+ (!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/Fallout.NuGet.Analysis.csproj b/src/Fallout.NuGet.Analysis/Fallout.NuGet.Analysis.csproj
new file mode 100644
index 00000000..0f6d6b7b
--- /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 00000000..f49d5a4e
--- /dev/null
+++ b/src/Fallout.NuGet.Analysis/Models.cs
@@ -0,0 +1,180 @@
+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();
+
+ /// 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
new file mode 100644
index 00000000..f777308d
--- /dev/null
+++ b/src/Fallout.NuGet.Analysis/PackageAnalyzer.cs
@@ -0,0 +1,279 @@
+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.
+ /// 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();
+
+ // 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))
+ {
+ var perPackage = new Dictionary>>(StringComparer.OrdinalIgnoreCase);
+ foreach (var project in tfmGroup)
+ {
+ 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 (!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);
+ }
+ }
+ }
+
+ var findings = new List();
+ foreach (var pair in conflicting)
+ {
+ 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 = 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,
+ 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 00000000..c33157f9
--- /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 00000000..39131917
--- /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 00000000..7336ba08
--- /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;
+ }
+}
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 2dec2260..946b43c2 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");