From 7c86d1bcdbac4d42c9ad228118669d91afb540c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Urba=C5=9B?= Date: Fri, 11 Sep 2026 18:21:14 +0200 Subject: [PATCH 01/27] test: add fixtures reproducing CPM and .slnx failures Adds a Central Package Management fixture solution (including a GlobalPackageReference, which is what actually triggers the crash) and a .slnx copy of the existing fixture solution, plus the two tests that exercise them. Both currently fail with the reported errors: Version '' for package 'AsyncFixer' is not valid. Project or solution file do not exist. --- .../Asdf/Asdf.csproj | 15 +++++++++ .../Asdf/Class1.cs | 6 ++++ .../CentralizedPackageManagement.sln | 31 +++++++++++++++++++ .../Directory.Packages.props | 15 +++++++++ .../Qwerty/Class1.cs | 6 ++++ .../Qwerty/Qwerty.csproj | 11 +++++++ .../Snitch.Tests.Fixtures.slnx | 11 +++++++ src/Snitch.Tests/ProgramTests.cs | 30 ++++++++++++++++++ 8 files changed, 125 insertions(+) create mode 100644 src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Asdf/Asdf.csproj create mode 100644 src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Asdf/Class1.cs create mode 100644 src/Snitch.Tests.Fixtures/CentralizedPackageManagement/CentralizedPackageManagement.sln create mode 100644 src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Directory.Packages.props create mode 100644 src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Qwerty/Class1.cs create mode 100644 src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Qwerty/Qwerty.csproj create mode 100644 src/Snitch.Tests.Fixtures/Snitch.Tests.Fixtures.slnx diff --git a/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Asdf/Asdf.csproj b/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Asdf/Asdf.csproj new file mode 100644 index 0000000..90557d5 --- /dev/null +++ b/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Asdf/Asdf.csproj @@ -0,0 +1,15 @@ + + + + net8.0 + + + + + + + + + + + diff --git a/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Asdf/Class1.cs b/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Asdf/Class1.cs new file mode 100644 index 0000000..1442fe0 --- /dev/null +++ b/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Asdf/Class1.cs @@ -0,0 +1,6 @@ +namespace Asdf +{ + public class Class1 + { + } +} diff --git a/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/CentralizedPackageManagement.sln b/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/CentralizedPackageManagement.sln new file mode 100644 index 0000000..df60573 --- /dev/null +++ b/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/CentralizedPackageManagement.sln @@ -0,0 +1,31 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.13.36105.23 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Asdf", "Asdf\Asdf.csproj", "{0026E591-BDA2-59CD-C237-1513DA9668F8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Qwerty", "Qwerty\Qwerty.csproj", "{D9EE6D9A-329C-4B70-831C-EBDB4498C490}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {0026E591-BDA2-59CD-C237-1513DA9668F8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0026E591-BDA2-59CD-C237-1513DA9668F8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0026E591-BDA2-59CD-C237-1513DA9668F8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0026E591-BDA2-59CD-C237-1513DA9668F8}.Release|Any CPU.Build.0 = Release|Any CPU + {D9EE6D9A-329C-4B70-831C-EBDB4498C490}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D9EE6D9A-329C-4B70-831C-EBDB4498C490}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D9EE6D9A-329C-4B70-831C-EBDB4498C490}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D9EE6D9A-329C-4B70-831C-EBDB4498C490}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {95C9EDD6-8E73-4BC8-9DD0-D25CE6E91111} + EndGlobalSection +EndGlobal diff --git a/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Directory.Packages.props b/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Directory.Packages.props new file mode 100644 index 0000000..af2bd60 --- /dev/null +++ b/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Directory.Packages.props @@ -0,0 +1,15 @@ + + + + true + + + + + + + + + + + diff --git a/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Qwerty/Class1.cs b/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Qwerty/Class1.cs new file mode 100644 index 0000000..d883fec --- /dev/null +++ b/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Qwerty/Class1.cs @@ -0,0 +1,6 @@ +namespace Qwerty +{ + public class Class1 + { + } +} diff --git a/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Qwerty/Qwerty.csproj b/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Qwerty/Qwerty.csproj new file mode 100644 index 0000000..cbab17f --- /dev/null +++ b/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Qwerty/Qwerty.csproj @@ -0,0 +1,11 @@ + + + + net8.0 + + + + + + + diff --git a/src/Snitch.Tests.Fixtures/Snitch.Tests.Fixtures.slnx b/src/Snitch.Tests.Fixtures/Snitch.Tests.Fixtures.slnx new file mode 100644 index 0000000..f1b633b --- /dev/null +++ b/src/Snitch.Tests.Fixtures/Snitch.Tests.Fixtures.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/Snitch.Tests/ProgramTests.cs b/src/Snitch.Tests/ProgramTests.cs index f264bcd..6fe11a3 100644 --- a/src/Snitch.Tests/ProgramTests.cs +++ b/src/Snitch.Tests/ProgramTests.cs @@ -190,6 +190,36 @@ public async Task Should_Return_Zero_Exit_Code_For_Thuuud_When_Running_With_NoPr await Verifier.Verify(output); } + [Fact] + [Expectation("Asdf", "Default")] + public async Task Should_Return_Expected_Result_For_Centralized_Package_Management() + { + // Given + var solution = Fixture.GetPath("CentralizedPackageManagement/CentralizedPackageManagement.sln"); + + // When + var (exitCode, output) = await Fixture.Run(solution); + + // Then + exitCode.ShouldBe(0); + await Verifier.Verify(output); + } + + [Fact] + [Expectation("Slnx", "Default")] + public async Task Should_Return_Expected_Result_For_Slnx_Solution_Not_Specifying_Framework() + { + // Given + var solution = Fixture.GetPath("Snitch.Tests.Fixtures.slnx"); + + // When + var (exitCode, output) = await Fixture.Run(solution); + + // Then + exitCode.ShouldBe(0); + await Verifier.Verify(output); + } + public sealed class Fixture { public static string GetPath(string path) From 3726bc156877438fa4bd0d12ed2f769185f4bfe3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Urba=C5=9B?= Date: Fri, 11 Sep 2026 18:22:25 +0200 Subject: [PATCH 02/27] fix: support Central Package Management A PackageReference managed centrally carries no Version of its own, and Buildalyzer reports its Version metadata as an empty string. The package was either dropped silently or, for a GlobalPackageReference, crashed the whole run with 'Version '' for package 'X' is not valid.' Packages without a version are now kept with no version and no range, and two such references to the same package compare as the same version, since the central version governs both. Closes #39. Closes #52. --- .../Expectations/Asdf.Default.verified.txt | 13 +++++++++++ src/Snitch/Analysis/Models/Package.cs | 23 ++++++++++++------- src/Snitch/Analysis/ProjectBuilder.cs | 8 +++---- 3 files changed, 31 insertions(+), 13 deletions(-) create mode 100644 src/Snitch.Tests/Expectations/Asdf.Default.verified.txt diff --git a/src/Snitch.Tests/Expectations/Asdf.Default.verified.txt b/src/Snitch.Tests/Expectations/Asdf.Default.verified.txt new file mode 100644 index 0000000..90dc103 --- /dev/null +++ b/src/Snitch.Tests/Expectations/Asdf.Default.verified.txt @@ -0,0 +1,13 @@ +Analyzing... +Analyzing CentralizedPackageManagement.sln +Analyzing Asdf... + * Analyzing Qwerty (net8.0)... + +╭──────────────────────────────────────────╮ +│ Packages that can be removed from Asdf: │ +│ ┌──────────────┬───────────────────────┐ │ +│ │ Package │ Referenced by │ │ +│ ├──────────────┼───────────────────────┤ │ +│ │ Autofac │ Qwerty │ │ +│ └──────────────┴───────────────────────┘ │ +╰──────────────────────────────────────────╯ \ No newline at end of file diff --git a/src/Snitch/Analysis/Models/Package.cs b/src/Snitch/Analysis/Models/Package.cs index eb6374d..cf96544 100644 --- a/src/Snitch/Analysis/Models/Package.cs +++ b/src/Snitch/Analysis/Models/Package.cs @@ -45,7 +45,12 @@ public bool IsGreaterThan(Package package, out bool indeterminate) public bool IsSameVersion(Package package) { - if (Version != null && package.Version != null) + if (Version == null && Range == null && package.Version == null && package.Range == null) + { + // Neither reference carries a version, so a central one governs both. + return true; + } + else if (Version != null && package.Version != null) { // Version == Version return new VersionComparer().Equals(Version, package.Version); @@ -69,7 +74,7 @@ public string GetVersionString() return Range?.OriginalString ?? "?"; } - public Package(string name, string version, string? privateAssets) + public Package(string name, string? version, string? privateAssets) { Name = name ?? throw new ArgumentNullException(nameof(name)); PrivateAssets = privateAssets; @@ -79,16 +84,18 @@ public Package(string name, string version, string? privateAssets) Version = semanticVersion; Range = null; } - else + else if (VersionRange.TryParse(version, out var range)) { - if (!VersionRange.TryParse(version, out var range)) - { - throw new ArgumentException($"Version '{version}' for package '{name}' is not valid.", nameof(version)); - } - Version = null; Range = range; } + else + { + // No version at all. Central Package Management leaves the version + // off the reference itself, so this is expected rather than an error. + Version = null; + Range = null; + } } } } \ No newline at end of file diff --git a/src/Snitch/Analysis/ProjectBuilder.cs b/src/Snitch/Analysis/ProjectBuilder.cs index dbd30c3..24f7430 100644 --- a/src/Snitch/Analysis/ProjectBuilder.cs +++ b/src/Snitch/Analysis/ProjectBuilder.cs @@ -101,12 +101,10 @@ private Project Build( // Get the package references. foreach (var packageReference in result.PackageReferences) { - if (packageReference.Value.TryGetValue("Version", out var version)) - { - var privateAssets = packageReference.Value.GetValueOrDefault("PrivateAssets"); + var version = packageReference.Value.GetValueOrDefault("Version"); + var privateAssets = packageReference.Value.GetValueOrDefault("PrivateAssets"); - project.Packages.Add(new Package(packageReference.Key, version, privateAssets)); - } + project.Packages.Add(new Package(packageReference.Key, version, privateAssets)); } // Analyze all project references. From 42956cc1083c8becc96b094fb4db2bbd3435f20b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Urba=C5=9B?= Date: Fri, 11 Sep 2026 18:23:04 +0200 Subject: [PATCH 03/27] fix: compare equal version ranges correctly IsSameVersion computed the range-to-range comparison and threw the result away, so two identical version ranges always reported as different. --- src/Snitch/Analysis/Models/Package.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Snitch/Analysis/Models/Package.cs b/src/Snitch/Analysis/Models/Package.cs index cf96544..0733492 100644 --- a/src/Snitch/Analysis/Models/Package.cs +++ b/src/Snitch/Analysis/Models/Package.cs @@ -58,7 +58,7 @@ public bool IsSameVersion(Package package) else if (Range != null && package.Range != null) { // Range == Range - new VersionRangeComparer().Equals(Range, package.Range); + return new VersionRangeComparer().Equals(Range, package.Range); } return false; From e5434df322fce45d5b171228505f9e076f290980 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Urba=C5=9B?= Date: Fri, 11 Sep 2026 18:24:46 +0200 Subject: [PATCH 04/27] feat: support slnx solution files Solution paths ending in .slnx are read as XML and every descendant Project element's Path is resolved, so projects nested in solution folders are picked up too. Directory probing now finds a .slnx as well. Paths in a .slnx use backslashes, so they are normalised for the current platform. Closes #51. --- .../Expectations/Slnx.Default.verified.txt | 49 +++++++++++++++++++ src/Snitch/Analysis/Utilities/PathUtility.cs | 23 ++++++++- 2 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 src/Snitch.Tests/Expectations/Slnx.Default.verified.txt diff --git a/src/Snitch.Tests/Expectations/Slnx.Default.verified.txt b/src/Snitch.Tests/Expectations/Slnx.Default.verified.txt new file mode 100644 index 0000000..55c85aa --- /dev/null +++ b/src/Snitch.Tests/Expectations/Slnx.Default.verified.txt @@ -0,0 +1,49 @@ +Analyzing... +Analyzing Snitch.Tests.Fixtures.slnx +Analyzing Foo... +Analyzing Bar... +Analyzing Baz... +Analyzing Qux... +Analyzing Zap... +Analyzing Quux... +Analyzing Quuux... +Analyzing Thud... +Analyzing Thuuud... + +╭─────────────────────────────────────────────────────────────────╮ +│ Packages that can be removed from Bar: │ +│ ┌──────────────────────┬──────────────────────────────────────┐ │ +│ │ Package │ Referenced by │ │ +│ ├──────────────────────┼──────────────────────────────────────┤ │ +│ │ Autofac │ Foo │ │ +│ └──────────────────────┴──────────────────────────────────────┘ │ +│ │ +│ Packages that can be removed from Baz: │ +│ ┌──────────────────────┬──────────────────────────────────────┐ │ +│ │ Package │ Referenced by │ │ +│ ├──────────────────────┼──────────────────────────────────────┤ │ +│ │ Autofac │ Foo │ │ +│ └──────────────────────┴──────────────────────────────────────┘ │ +│ │ +│ Packages that might be removed from Qux: │ +│ ┌───────────┬───────────┬─────────────────────────────────────┐ │ +│ │ Package │ Version │ Reason │ │ +│ ├───────────┼───────────┼─────────────────────────────────────┤ │ +│ │ Autofac │ 4.9.3 │ Downgraded from 4.9.4 in Foo │ │ +│ └───────────┴───────────┴─────────────────────────────────────┘ │ +│ │ +│ Packages that might be removed from Zap: │ +│ ┌──────────────────┬──────────┬───────────────────────────────┐ │ +│ │ Package │ Version │ Reason │ │ +│ ├──────────────────┼──────────┼───────────────────────────────┤ │ +│ │ Newtonsoft.Json │ 12.0.3 │ Updated from 12.0.1 in Foo │ │ +│ │ Autofac │ 4.9.3 │ Downgraded from 4.9.4 in Foo │ │ +│ └──────────────────┴──────────┴───────────────────────────────┘ │ +│ │ +│ Packages that might be removed from Thuuud: │ +│ ┌─────────────────┬──────────────┬────────────────────────────┐ │ +│ │ Package │ Version │ Reason │ │ +│ ├─────────────────┼──────────────┼────────────────────────────┤ │ +│ │ Newtonsoft.Json │ 13.0.2-beta2 │ Updated from 12.0.1 in Foo │ │ +│ └─────────────────┴──────────────┴────────────────────────────┘ │ +╰─────────────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/src/Snitch/Analysis/Utilities/PathUtility.cs b/src/Snitch/Analysis/Utilities/PathUtility.cs index 1f7d35e..18cc215 100644 --- a/src/Snitch/Analysis/Utilities/PathUtility.cs +++ b/src/Snitch/Analysis/Utilities/PathUtility.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Xml.Linq; using Microsoft.Build.Construction; namespace Snitch.Analysis.Utilities @@ -53,6 +54,11 @@ private static List GetProjectsFromFile(string path) return GetProjectsFromSolution(path); } + if (path.EndsWith(".slnx", StringComparison.InvariantCulture)) + { + return GetProjectsFromSlnxSolution(path); + } + throw new InvalidOperationException("Project or solution file do not exist."); } @@ -60,7 +66,7 @@ private static List FindProjects(string? root, out string entry) { root ??= Environment.CurrentDirectory; - var slns = Directory.GetFiles(root, "*.sln"); + var slns = Directory.GetFiles(root, "*.sln").Concat(Directory.GetFiles(root, "*.slnx")).ToArray(); if (slns.Length == 0) { @@ -84,7 +90,7 @@ private static List FindProjects(string? root, out string entry) else { entry = slns[0]; - return GetProjectsFromSolution(slns[0]); + return GetProjectsFromFile(slns[0]); } } @@ -93,5 +99,18 @@ private static List GetProjectsFromSolution(string solution) var solutionFile = SolutionFile.Parse(solution); return solutionFile.ProjectsInOrder.Where(p => p.ProjectType == SolutionProjectType.KnownToBeMSBuildFormat).Select(p => p.AbsolutePath).Distinct().ToList(); } + + private static List GetProjectsFromSlnxSolution(string solution) + { + var directory = Path.GetDirectoryName(solution) ?? Environment.CurrentDirectory; + + // Projects can be nested in folders, so look at every descendant. + return XDocument.Load(solution).Descendants("Project") + .Select(project => project.Attribute("Path")?.Value) + .Where(path => path != null && path.EndsWith("proj", StringComparison.OrdinalIgnoreCase)) + .Select(path => Path.GetFullPath(Path.Combine(directory, path!.Replace('\\', Path.DirectorySeparatorChar)))) + .Distinct() + .ToList(); + } } } \ No newline at end of file From 49d60ab2c593ba087651c4429c343d0e3ef71017 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Urba=C5=9B?= Date: Fri, 11 Sep 2026 18:26:12 +0200 Subject: [PATCH 05/27] test: cover package version parsing Covers references without a version, exact version ranges, and how those compare against each other. --- src/Snitch.Tests/PackageTests.cs | 68 ++++++++++++++++++++++++++++++++ src/Snitch/Snitch.csproj | 4 ++ 2 files changed, 72 insertions(+) create mode 100644 src/Snitch.Tests/PackageTests.cs diff --git a/src/Snitch.Tests/PackageTests.cs b/src/Snitch.Tests/PackageTests.cs new file mode 100644 index 0000000..1215582 --- /dev/null +++ b/src/Snitch.Tests/PackageTests.cs @@ -0,0 +1,68 @@ +using Shouldly; +using Snitch.Analysis; +using Xunit; + +namespace Sntich.Tests +{ + public sealed class PackageTests + { + [Theory] + [InlineData("")] + [InlineData(null)] + public void Should_Not_Throw_For_A_Package_Without_A_Version(string version) + { + // Given, When + var package = new Package("Autofac", version, null); + + // Then + package.Version.ShouldBeNull(); + package.Range.ShouldBeNull(); + package.GetVersionString().ShouldBe("?"); + } + + [Fact] + public void Should_Consider_Two_Packages_Without_A_Version_To_Be_The_Same_Version() + { + // Given + var first = new Package("Autofac", null, null); + var second = new Package("Autofac", null, null); + + // When, Then + first.IsSameVersion(second).ShouldBeTrue(); + } + + [Fact] + public void Should_Parse_An_Exact_Version_Range() + { + // Given, When + var package = new Package("Autofac", "[3.5.4]", null); + + // Then + package.Version.ShouldBeNull(); + package.Range.ShouldNotBeNull(); + package.GetVersionString().ShouldBe("[3.5.4]"); + } + + [Fact] + public void Should_Consider_Two_Identical_Version_Ranges_To_Be_The_Same_Version() + { + // Given + var first = new Package("Autofac", "[3.5.4]", null); + var second = new Package("Autofac", "[3.5.4]", null); + + // When, Then + first.IsSameVersion(second).ShouldBeTrue(); + } + + [Fact] + public void Should_Not_Consider_A_Versionless_Package_The_Same_As_A_Versioned_One() + { + // Given + var versionless = new Package("Autofac", null, null); + var versioned = new Package("Autofac", "4.9.4", null); + + // When, Then + versionless.IsSameVersion(versioned).ShouldBeFalse(); + } + } +} diff --git a/src/Snitch/Snitch.csproj b/src/Snitch/Snitch.csproj index b05f8ba..0f73d1b 100644 --- a/src/Snitch/Snitch.csproj +++ b/src/Snitch/Snitch.csproj @@ -40,6 +40,10 @@ + + + + From 88a8a3c61b178eee61f990a1cc25cd2b0e7652e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Urba=C5=9B?= Date: Fri, 11 Sep 2026 18:27:05 +0200 Subject: [PATCH 06/27] docs: mention slnx and Central Package Management support --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 6171a4d..7294a25 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,13 @@ target framework._ > snitch MyProject.csproj ``` +_Examine a solution. Both `.sln` and `.slnx` solutions are supported, +as are projects using Central Package Management._ + +``` +> snitch MySolution.slnx +``` + _Examine a specific project using a specific target framework moniker._ From a0f8637a077170cf131b9a38ad48431707528495 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Urba=C5=9B?= Date: Fri, 11 Sep 2026 18:34:35 +0200 Subject: [PATCH 07/27] test: show that versions are lost under Central Package Management Extends the CPM fixture with a centrally pinned pre-release package and a VersionOverride, and adds a --strict --no-prerelease test. Both currently produce wrong results, because a centrally managed reference carries no version: - the overridden Autofac 4.9.3 is reported as removable instead of a downgrade from 4.9.4 - the pre-release Newtonsoft.Json is not reported at all and the strict run exits 0 --- .../CentralizedPackageManagement/Asdf/Asdf.csproj | 3 ++- .../Directory.Packages.props | 1 + .../Qwerty/Qwerty.csproj | 1 + src/Snitch.Tests/ProgramTests.cs | 15 +++++++++++++++ 4 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Asdf/Asdf.csproj b/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Asdf/Asdf.csproj index 90557d5..686de37 100644 --- a/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Asdf/Asdf.csproj +++ b/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Asdf/Asdf.csproj @@ -5,7 +5,8 @@ - + + diff --git a/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Directory.Packages.props b/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Directory.Packages.props index af2bd60..8de45f2 100644 --- a/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Directory.Packages.props +++ b/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Directory.Packages.props @@ -6,6 +6,7 @@ + diff --git a/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Qwerty/Qwerty.csproj b/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Qwerty/Qwerty.csproj index cbab17f..0d39008 100644 --- a/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Qwerty/Qwerty.csproj +++ b/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Qwerty/Qwerty.csproj @@ -6,6 +6,7 @@ + diff --git a/src/Snitch.Tests/ProgramTests.cs b/src/Snitch.Tests/ProgramTests.cs index 6fe11a3..a879c58 100644 --- a/src/Snitch.Tests/ProgramTests.cs +++ b/src/Snitch.Tests/ProgramTests.cs @@ -205,6 +205,21 @@ public async Task Should_Return_Expected_Result_For_Centralized_Package_Manageme await Verifier.Verify(output); } + [Fact] + [Expectation("Asdf", "Strict_NoPreRelease")] + public async Task Should_Return_Expected_Result_For_Centralized_Package_Management_When_Running_With_NoPreRelease() + { + // Given + var solution = Fixture.GetPath("CentralizedPackageManagement/CentralizedPackageManagement.sln"); + + // When + var (exitCode, output) = await Fixture.Run(solution, "--strict", "--no-prerelease"); + + // Then + exitCode.ShouldBe(-1); + await Verifier.Verify(output); + } + [Fact] [Expectation("Slnx", "Default")] public async Task Should_Return_Expected_Result_For_Slnx_Solution_Not_Specifying_Framework() From 344460b645403892965d72ec553ebccf32158115 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Urba=C5=9B?= Date: Fri, 11 Sep 2026 18:36:44 +0200 Subject: [PATCH 08/27] fix: resolve versions of centrally managed packages A centrally managed PackageReference carries no version of its own, so every version dependent feature was dead under Central Package Management: differing versions were reported as removable rather than as an update or a downgrade, and --no-prerelease reported nothing at all. The version restore settled on is read from project.assets.json, which also accounts for VersionOverride, giving the same results as a project that pins its versions inline. --- .../Expectations/Asdf.Default.verified.txt | 23 +++++++---- .../Asdf.Strict_NoPreRelease.verified.txt | 28 +++++++++++++ src/Snitch/Analysis/ProjectBuilder.cs | 41 +++++++++++++++++++ 3 files changed, 84 insertions(+), 8 deletions(-) create mode 100644 src/Snitch.Tests/Expectations/Asdf.Strict_NoPreRelease.verified.txt diff --git a/src/Snitch.Tests/Expectations/Asdf.Default.verified.txt b/src/Snitch.Tests/Expectations/Asdf.Default.verified.txt index 90dc103..8c21732 100644 --- a/src/Snitch.Tests/Expectations/Asdf.Default.verified.txt +++ b/src/Snitch.Tests/Expectations/Asdf.Default.verified.txt @@ -3,11 +3,18 @@ Analyzing CentralizedPackageManagement.sln Analyzing Asdf... * Analyzing Qwerty (net8.0)... -╭──────────────────────────────────────────╮ -│ Packages that can be removed from Asdf: │ -│ ┌──────────────┬───────────────────────┐ │ -│ │ Package │ Referenced by │ │ -│ ├──────────────┼───────────────────────┤ │ -│ │ Autofac │ Qwerty │ │ -│ └──────────────┴───────────────────────┘ │ -╰──────────────────────────────────────────╯ \ No newline at end of file +╭─────────────────────────────────────────────────────────╮ +│ Packages that can be removed from Asdf: │ +│ ┌────────────────────────────┬────────────────────────┐ │ +│ │ Package │ Referenced by │ │ +│ ├────────────────────────────┼────────────────────────┤ │ +│ │ Newtonsoft.Json │ Qwerty │ │ +│ └────────────────────────────┴────────────────────────┘ │ +│ │ +│ Packages that might be removed from Asdf: │ +│ ┌─────────┬─────────┬─────────────────────────────────┐ │ +│ │ Package │ Version │ Reason │ │ +│ ├─────────┼─────────┼─────────────────────────────────┤ │ +│ │ Autofac │ 4.9.3 │ Downgraded from 4.9.4 in Qwerty │ │ +│ └─────────┴─────────┴─────────────────────────────────┘ │ +╰─────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/src/Snitch.Tests/Expectations/Asdf.Strict_NoPreRelease.verified.txt b/src/Snitch.Tests/Expectations/Asdf.Strict_NoPreRelease.verified.txt new file mode 100644 index 0000000..d5953b7 --- /dev/null +++ b/src/Snitch.Tests/Expectations/Asdf.Strict_NoPreRelease.verified.txt @@ -0,0 +1,28 @@ +Analyzing... +Analyzing CentralizedPackageManagement.sln +Analyzing Asdf... + * Analyzing Qwerty (net8.0)... + +╭─────────────────────────────────────────────────────────╮ +│ Packages that can be removed from Asdf: │ +│ ┌────────────────────────────┬────────────────────────┐ │ +│ │ Package │ Referenced by │ │ +│ ├────────────────────────────┼────────────────────────┤ │ +│ │ Newtonsoft.Json │ Qwerty │ │ +│ └────────────────────────────┴────────────────────────┘ │ +│ │ +│ Packages that might be removed from Asdf: │ +│ ┌─────────┬─────────┬─────────────────────────────────┐ │ +│ │ Package │ Version │ Reason │ │ +│ ├─────────┼─────────┼─────────────────────────────────┤ │ +│ │ Autofac │ 4.9.3 │ Downgraded from 4.9.4 in Qwerty │ │ +│ └─────────┴─────────┴─────────────────────────────────┘ │ +│ │ +│ Projects with pre-release package references: │ +│ ┌────────────┬──────────────────────┬─────────────────┐ │ +│ │ Project │ Package │ Version │ │ +│ ├────────────┼──────────────────────┼─────────────────┤ │ +│ │ Asdf │ Newtonsoft.Json │ 13.0.2-beta2 │ │ +│ │ Qwerty │ Newtonsoft.Json │ 13.0.2-beta2 │ │ +│ └────────────┴──────────────────────┴─────────────────┘ │ +╰─────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/src/Snitch/Analysis/ProjectBuilder.cs b/src/Snitch/Analysis/ProjectBuilder.cs index 24f7430..8f21396 100644 --- a/src/Snitch/Analysis/ProjectBuilder.cs +++ b/src/Snitch/Analysis/ProjectBuilder.cs @@ -3,6 +3,8 @@ using System.IO; using System.Linq; using Buildalyzer; +using NuGet.Frameworks; +using NuGet.ProjectModel; using Snitch.Analysis.Utilities; using Spectre.Console; @@ -99,11 +101,19 @@ private Project Build( built.Add(Path.GetFileName(path), project); // Get the package references. + var restoredVersions = GetRestoredVersions(assetPath, project.TargetFramework); foreach (var packageReference in result.PackageReferences) { var version = packageReference.Value.GetValueOrDefault("Version"); var privateAssets = packageReference.Value.GetValueOrDefault("PrivateAssets"); + if (string.IsNullOrWhiteSpace(version)) + { + // Central Package Management keeps the version out of the reference, + // so fall back to the version that restore actually settled on. + version = restoredVersions.GetValueOrDefault(packageReference.Key); + } + project.Packages.Add(new Package(packageReference.Key, version, privateAssets)); } @@ -139,6 +149,37 @@ private Project Build( return project; } + private static Dictionary GetRestoredVersions(string? lockFilePath, string? targetFramework) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (string.IsNullOrWhiteSpace(lockFilePath) || string.IsNullOrWhiteSpace(targetFramework)) + { + return result; + } + + var lockFile = new LockFileFormat().Read(lockFilePath); + var framework = NuGetFramework.Parse(targetFramework); + var target = lockFile.PackageSpec.TargetFrameworks.FirstOrDefault( + x => x.FrameworkName.Framework.Equals(framework.Framework, StringComparison.OrdinalIgnoreCase)); + + if (target == null) + { + return result; + } + + foreach (var dependency in target.Dependencies) + { + var version = dependency.LibraryRange?.VersionRange?.MinVersion; + if (version != null) + { + result[dependency.Name] = version.ToNormalizedString(); + } + } + + return result; + } + private IAnalyzerResult? Build(AnalyzerManager manager, Project project, string? tfm, int indentation) { var prefix = new string(' ', indentation * 2); From 905b95c1179f54658e20b9f06da3349686332489 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Urba=C5=9B?= Date: Fri, 11 Sep 2026 18:38:08 +0200 Subject: [PATCH 09/27] test: cover slnx solution folders and directory probing Projects nested in solution folders are found, and a directory holding a single .slnx is picked up without being named explicitly. --- .../SolutionFolders/SolutionFolders.slnx | 8 +++++ .../Expectations/Slnx.Discovered.verified.txt | 13 ++++++++ .../Slnx.SolutionFolders.verified.txt | 13 ++++++++ src/Snitch.Tests/ProgramTests.cs | 30 +++++++++++++++++++ 4 files changed, 64 insertions(+) create mode 100644 src/Snitch.Tests.Fixtures/SolutionFolders/SolutionFolders.slnx create mode 100644 src/Snitch.Tests/Expectations/Slnx.Discovered.verified.txt create mode 100644 src/Snitch.Tests/Expectations/Slnx.SolutionFolders.verified.txt diff --git a/src/Snitch.Tests.Fixtures/SolutionFolders/SolutionFolders.slnx b/src/Snitch.Tests.Fixtures/SolutionFolders/SolutionFolders.slnx new file mode 100644 index 0000000..3866806 --- /dev/null +++ b/src/Snitch.Tests.Fixtures/SolutionFolders/SolutionFolders.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/Snitch.Tests/Expectations/Slnx.Discovered.verified.txt b/src/Snitch.Tests/Expectations/Slnx.Discovered.verified.txt new file mode 100644 index 0000000..7a02a5c --- /dev/null +++ b/src/Snitch.Tests/Expectations/Slnx.Discovered.verified.txt @@ -0,0 +1,13 @@ +Analyzing... +Analyzing SolutionFolders.slnx +Analyzing Foo... +Analyzing Bar... + +╭─────────────────────────────────────────╮ +│ Packages that can be removed from Bar: │ +│ ┌──────────────┬──────────────────────┐ │ +│ │ Package │ Referenced by │ │ +│ ├──────────────┼──────────────────────┤ │ +│ │ Autofac │ Foo │ │ +│ └──────────────┴──────────────────────┘ │ +╰─────────────────────────────────────────╯ \ No newline at end of file diff --git a/src/Snitch.Tests/Expectations/Slnx.SolutionFolders.verified.txt b/src/Snitch.Tests/Expectations/Slnx.SolutionFolders.verified.txt new file mode 100644 index 0000000..7a02a5c --- /dev/null +++ b/src/Snitch.Tests/Expectations/Slnx.SolutionFolders.verified.txt @@ -0,0 +1,13 @@ +Analyzing... +Analyzing SolutionFolders.slnx +Analyzing Foo... +Analyzing Bar... + +╭─────────────────────────────────────────╮ +│ Packages that can be removed from Bar: │ +│ ┌──────────────┬──────────────────────┐ │ +│ │ Package │ Referenced by │ │ +│ ├──────────────┼──────────────────────┤ │ +│ │ Autofac │ Foo │ │ +│ └──────────────┴──────────────────────┘ │ +╰─────────────────────────────────────────╯ \ No newline at end of file diff --git a/src/Snitch.Tests/ProgramTests.cs b/src/Snitch.Tests/ProgramTests.cs index a879c58..9fdb3b9 100644 --- a/src/Snitch.Tests/ProgramTests.cs +++ b/src/Snitch.Tests/ProgramTests.cs @@ -235,6 +235,36 @@ public async Task Should_Return_Expected_Result_For_Slnx_Solution_Not_Specifying await Verifier.Verify(output); } + [Fact] + [Expectation("Slnx", "SolutionFolders")] + public async Task Should_Return_Expected_Result_For_Slnx_Solution_With_Solution_Folders() + { + // Given + var solution = Fixture.GetPath("SolutionFolders/SolutionFolders.slnx"); + + // When + var (exitCode, output) = await Fixture.Run(solution); + + // Then + exitCode.ShouldBe(0); + await Verifier.Verify(output); + } + + [Fact] + [Expectation("Slnx", "Discovered")] + public async Task Should_Find_A_Slnx_Solution_In_A_Directory() + { + // Given + var directory = Fixture.GetPath("SolutionFolders"); + + // When + var (exitCode, output) = await Fixture.Run(directory); + + // Then + exitCode.ShouldBe(0); + await Verifier.Verify(output); + } + public sealed class Fixture { public static string GetPath(string path) From 67db8bed5ca086db28e3f82c0750d73ab06e3777 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Urba=C5=9B?= Date: Fri, 11 Sep 2026 18:48:52 +0200 Subject: [PATCH 10/27] fix: don't crash on a multi targeting project when --tfm is given Building a multi targeting project also yields a result without a target framework. Passing that to NuGetFramework.Parse failed the whole run with 'Value cannot be null. (Parameter 'folderName')', so --tfm could not be used on any project with a TargetFrameworks property. --- src/Snitch.Tests.Fixtures/Grault/Class1.cs | 6 ++++++ src/Snitch.Tests.Fixtures/Grault/Grault.csproj | 15 +++++++++++++++ .../Expectations/Grault.net8.0.verified.txt | 13 +++++++++++++ src/Snitch.Tests/ProgramTests.cs | 15 +++++++++++++++ src/Snitch/Analysis/ProjectBuilder.cs | 2 +- .../Extensions/AnalyzerResultExtensions.cs | 6 +++++- 6 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 src/Snitch.Tests.Fixtures/Grault/Class1.cs create mode 100644 src/Snitch.Tests.Fixtures/Grault/Grault.csproj create mode 100644 src/Snitch.Tests/Expectations/Grault.net8.0.verified.txt diff --git a/src/Snitch.Tests.Fixtures/Grault/Class1.cs b/src/Snitch.Tests.Fixtures/Grault/Class1.cs new file mode 100644 index 0000000..feaf090 --- /dev/null +++ b/src/Snitch.Tests.Fixtures/Grault/Class1.cs @@ -0,0 +1,6 @@ +namespace Grault +{ + public class Class1 + { + } +} diff --git a/src/Snitch.Tests.Fixtures/Grault/Grault.csproj b/src/Snitch.Tests.Fixtures/Grault/Grault.csproj new file mode 100644 index 0000000..bb0657c --- /dev/null +++ b/src/Snitch.Tests.Fixtures/Grault/Grault.csproj @@ -0,0 +1,15 @@ + + + + netstandard2.0;net8.0 + + + + + + + + + + + diff --git a/src/Snitch.Tests/Expectations/Grault.net8.0.verified.txt b/src/Snitch.Tests/Expectations/Grault.net8.0.verified.txt new file mode 100644 index 0000000..873f050 --- /dev/null +++ b/src/Snitch.Tests/Expectations/Grault.net8.0.verified.txt @@ -0,0 +1,13 @@ +Analyzing... +Analyzing Grault.csproj +Analyzing Grault (net8.0)... + * Analyzing Foo (net8.0)... + +╭────────────────────────────────────────────╮ +│ Packages that can be removed from Grault: │ +│ ┌───────────────┬────────────────────────┐ │ +│ │ Package │ Referenced by │ │ +│ ├───────────────┼────────────────────────┤ │ +│ │ Autofac │ Foo │ │ +│ └───────────────┴────────────────────────┘ │ +╰────────────────────────────────────────────╯ \ No newline at end of file diff --git a/src/Snitch.Tests/ProgramTests.cs b/src/Snitch.Tests/ProgramTests.cs index 9fdb3b9..677951d 100644 --- a/src/Snitch.Tests/ProgramTests.cs +++ b/src/Snitch.Tests/ProgramTests.cs @@ -235,6 +235,21 @@ public async Task Should_Return_Expected_Result_For_Slnx_Solution_Not_Specifying await Verifier.Verify(output); } + [Fact] + [Expectation("Grault", "net8.0")] + public async Task Should_Return_Expected_Result_For_A_Multi_Targeting_Project() + { + // Given + var project = Fixture.GetPath("Grault/Grault.csproj"); + + // When + var (exitCode, output) = await Fixture.Run(project, "--tfm", "net8.0"); + + // Then + exitCode.ShouldBe(0); + await Verifier.Verify(output); + } + [Fact] [Expectation("Slnx", "SolutionFolders")] public async Task Should_Return_Expected_Result_For_Slnx_Solution_With_Solution_Folders() diff --git a/src/Snitch/Analysis/ProjectBuilder.cs b/src/Snitch/Analysis/ProjectBuilder.cs index 8f21396..e87d6fb 100644 --- a/src/Snitch/Analysis/ProjectBuilder.cs +++ b/src/Snitch/Analysis/ProjectBuilder.cs @@ -200,7 +200,7 @@ private static Dictionary GetRestoredVersions(string? lockFilePa if (!string.IsNullOrWhiteSpace(tfm)) { var closest = results.GetNearestFrameworkMoniker(tfm); - results = results.Where(p => p.TargetFramework.Equals(closest, StringComparison.OrdinalIgnoreCase)); + results = results.Where(p => closest.Equals(p.TargetFramework, StringComparison.OrdinalIgnoreCase)); } return results.FirstOrDefault(); diff --git a/src/Snitch/Utilities/Extensions/AnalyzerResultExtensions.cs b/src/Snitch/Utilities/Extensions/AnalyzerResultExtensions.cs index 3a73aa2..e9acd27 100644 --- a/src/Snitch/Utilities/Extensions/AnalyzerResultExtensions.cs +++ b/src/Snitch/Utilities/Extensions/AnalyzerResultExtensions.cs @@ -15,7 +15,11 @@ internal static class AnalyzerResultExtensions public static string GetNearestFrameworkMoniker(this IEnumerable source, string framework) { var current = NuGetFramework.Parse(framework, DefaultFrameworkNameProvider.Instance); - return current.GetNearestFrameworkMoniker(source.Select(x => x.TargetFramework)); + + // A multi targeting project also yields a result without a target framework. + var candidates = source.Select(x => x.TargetFramework).Where(x => !string.IsNullOrWhiteSpace(x)); + + return current.GetNearestFrameworkMoniker(candidates); } private static string GetNearestFrameworkMoniker(this NuGetFramework framework, IEnumerable candidates) From 2470c72c76656b5e24ba56d41c63fa98a0cb2d2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Urba=C5=9B?= Date: Fri, 11 Sep 2026 18:53:32 +0200 Subject: [PATCH 11/27] test: cover a project referencing a centrally managed one A project outside the Central Package Management cone pins its version inline while the project it references manages it centrally. Without the version being resolved, the identical versions compared as a mismatch. --- src/Snitch.Tests.Fixtures/Migrating/Class1.cs | 6 ++++++ .../Migrating/Migrating.csproj | 17 +++++++++++++++++ .../Expectations/Migrating.Default.verified.txt | 13 +++++++++++++ src/Snitch.Tests/ProgramTests.cs | 15 +++++++++++++++ 4 files changed, 51 insertions(+) create mode 100644 src/Snitch.Tests.Fixtures/Migrating/Class1.cs create mode 100644 src/Snitch.Tests.Fixtures/Migrating/Migrating.csproj create mode 100644 src/Snitch.Tests/Expectations/Migrating.Default.verified.txt diff --git a/src/Snitch.Tests.Fixtures/Migrating/Class1.cs b/src/Snitch.Tests.Fixtures/Migrating/Class1.cs new file mode 100644 index 0000000..412a1fb --- /dev/null +++ b/src/Snitch.Tests.Fixtures/Migrating/Class1.cs @@ -0,0 +1,6 @@ +namespace Migrating +{ + public class Class1 + { + } +} diff --git a/src/Snitch.Tests.Fixtures/Migrating/Migrating.csproj b/src/Snitch.Tests.Fixtures/Migrating/Migrating.csproj new file mode 100644 index 0000000..4da458e --- /dev/null +++ b/src/Snitch.Tests.Fixtures/Migrating/Migrating.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + + + + + + + + + + + + diff --git a/src/Snitch.Tests/Expectations/Migrating.Default.verified.txt b/src/Snitch.Tests/Expectations/Migrating.Default.verified.txt new file mode 100644 index 0000000..70f43eb --- /dev/null +++ b/src/Snitch.Tests/Expectations/Migrating.Default.verified.txt @@ -0,0 +1,13 @@ +Analyzing... +Analyzing Migrating.csproj +Analyzing Migrating... + * Analyzing Qwerty (net8.0)... + +╭───────────────────────────────────────────────╮ +│ Packages that can be removed from Migrating: │ +│ ┌────────────────┬──────────────────────────┐ │ +│ │ Package │ Referenced by │ │ +│ ├────────────────┼──────────────────────────┤ │ +│ │ Autofac │ Qwerty │ │ +│ └────────────────┴──────────────────────────┘ │ +╰───────────────────────────────────────────────╯ \ No newline at end of file diff --git a/src/Snitch.Tests/ProgramTests.cs b/src/Snitch.Tests/ProgramTests.cs index 677951d..f09532d 100644 --- a/src/Snitch.Tests/ProgramTests.cs +++ b/src/Snitch.Tests/ProgramTests.cs @@ -235,6 +235,21 @@ public async Task Should_Return_Expected_Result_For_Slnx_Solution_Not_Specifying await Verifier.Verify(output); } + [Fact] + [Expectation("Migrating", "Default")] + public async Task Should_Return_Expected_Result_When_Only_The_Referenced_Project_Uses_Central_Package_Management() + { + // Given + var project = Fixture.GetPath("Migrating/Migrating.csproj"); + + // When + var (exitCode, output) = await Fixture.Run(project); + + // Then + exitCode.ShouldBe(0); + await Verifier.Verify(output); + } + [Fact] [Expectation("Grault", "net8.0")] public async Task Should_Return_Expected_Result_For_A_Multi_Targeting_Project() From c052990f3bd625ab60dbc30b1ae0d648d47d4954 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Urba=C5=9B?= Date: Fri, 11 Sep 2026 18:57:46 +0200 Subject: [PATCH 12/27] test: cover transitive depth and inline versions under central management Adds a third level to the fixture so a package provided by a grandchild project is reported, and a project that pins a version inline despite central management, where the inline version has to win. --- .../Asdf/Asdf.csproj | 1 + .../Directory.Packages.props | 1 + .../Qwerty/Qwerty.csproj | 4 ++++ .../CentralizedPackageManagement/Uiop/Class1.cs | 6 ++++++ .../Uiop/Uiop.csproj | 17 +++++++++++++++++ .../CentralizedPackageManagement/Zxcv/Class1.cs | 6 ++++++ .../Zxcv/Zxcv.csproj | 11 +++++++++++ .../Expectations/Asdf.Default.verified.txt | 2 ++ .../Asdf.Strict_NoPreRelease.verified.txt | 2 ++ .../Expectations/Migrating.Default.verified.txt | 1 + .../Expectations/Uiop.Default.verified.txt | 14 ++++++++++++++ src/Snitch.Tests/ProgramTests.cs | 15 +++++++++++++++ 12 files changed, 80 insertions(+) create mode 100644 src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Uiop/Class1.cs create mode 100644 src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Uiop/Uiop.csproj create mode 100644 src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Zxcv/Class1.cs create mode 100644 src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Zxcv/Zxcv.csproj create mode 100644 src/Snitch.Tests/Expectations/Uiop.Default.verified.txt diff --git a/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Asdf/Asdf.csproj b/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Asdf/Asdf.csproj index 686de37..977922f 100644 --- a/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Asdf/Asdf.csproj +++ b/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Asdf/Asdf.csproj @@ -7,6 +7,7 @@ + diff --git a/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Directory.Packages.props b/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Directory.Packages.props index 8de45f2..19c55f6 100644 --- a/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Directory.Packages.props +++ b/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Directory.Packages.props @@ -7,6 +7,7 @@ + diff --git a/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Qwerty/Qwerty.csproj b/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Qwerty/Qwerty.csproj index 0d39008..1913282 100644 --- a/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Qwerty/Qwerty.csproj +++ b/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Qwerty/Qwerty.csproj @@ -9,4 +9,8 @@ + + + + diff --git a/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Uiop/Class1.cs b/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Uiop/Class1.cs new file mode 100644 index 0000000..4bed7d9 --- /dev/null +++ b/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Uiop/Class1.cs @@ -0,0 +1,6 @@ +namespace Uiop +{ + public class Class1 + { + } +} diff --git a/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Uiop/Uiop.csproj b/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Uiop/Uiop.csproj new file mode 100644 index 0000000..c891b41 --- /dev/null +++ b/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Uiop/Uiop.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + + + + + + + + + + + + diff --git a/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Zxcv/Class1.cs b/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Zxcv/Class1.cs new file mode 100644 index 0000000..f7ee6dc --- /dev/null +++ b/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Zxcv/Class1.cs @@ -0,0 +1,6 @@ +namespace Zxcv +{ + public class Class1 + { + } +} diff --git a/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Zxcv/Zxcv.csproj b/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Zxcv/Zxcv.csproj new file mode 100644 index 0000000..d644bdd --- /dev/null +++ b/src/Snitch.Tests.Fixtures/CentralizedPackageManagement/Zxcv/Zxcv.csproj @@ -0,0 +1,11 @@ + + + + net8.0 + + + + + + + diff --git a/src/Snitch.Tests/Expectations/Asdf.Default.verified.txt b/src/Snitch.Tests/Expectations/Asdf.Default.verified.txt index 8c21732..208fb80 100644 --- a/src/Snitch.Tests/Expectations/Asdf.Default.verified.txt +++ b/src/Snitch.Tests/Expectations/Asdf.Default.verified.txt @@ -2,6 +2,7 @@ Analyzing CentralizedPackageManagement.sln Analyzing Asdf... * Analyzing Qwerty (net8.0)... + * Analyzing Zxcv (net8.0)... ╭─────────────────────────────────────────────────────────╮ │ Packages that can be removed from Asdf: │ @@ -9,6 +10,7 @@ Analyzing Asdf... │ │ Package │ Referenced by │ │ │ ├────────────────────────────┼────────────────────────┤ │ │ │ Newtonsoft.Json │ Qwerty │ │ +│ │ Serilog │ Zxcv │ │ │ └────────────────────────────┴────────────────────────┘ │ │ │ │ Packages that might be removed from Asdf: │ diff --git a/src/Snitch.Tests/Expectations/Asdf.Strict_NoPreRelease.verified.txt b/src/Snitch.Tests/Expectations/Asdf.Strict_NoPreRelease.verified.txt index d5953b7..7a980d7 100644 --- a/src/Snitch.Tests/Expectations/Asdf.Strict_NoPreRelease.verified.txt +++ b/src/Snitch.Tests/Expectations/Asdf.Strict_NoPreRelease.verified.txt @@ -2,6 +2,7 @@ Analyzing CentralizedPackageManagement.sln Analyzing Asdf... * Analyzing Qwerty (net8.0)... + * Analyzing Zxcv (net8.0)... ╭─────────────────────────────────────────────────────────╮ │ Packages that can be removed from Asdf: │ @@ -9,6 +10,7 @@ Analyzing Asdf... │ │ Package │ Referenced by │ │ │ ├────────────────────────────┼────────────────────────┤ │ │ │ Newtonsoft.Json │ Qwerty │ │ +│ │ Serilog │ Zxcv │ │ │ └────────────────────────────┴────────────────────────┘ │ │ │ │ Packages that might be removed from Asdf: │ diff --git a/src/Snitch.Tests/Expectations/Migrating.Default.verified.txt b/src/Snitch.Tests/Expectations/Migrating.Default.verified.txt index 70f43eb..fd563e7 100644 --- a/src/Snitch.Tests/Expectations/Migrating.Default.verified.txt +++ b/src/Snitch.Tests/Expectations/Migrating.Default.verified.txt @@ -2,6 +2,7 @@ Analyzing Migrating.csproj Analyzing Migrating... * Analyzing Qwerty (net8.0)... + * Analyzing Zxcv (net8.0)... ╭───────────────────────────────────────────────╮ │ Packages that can be removed from Migrating: │ diff --git a/src/Snitch.Tests/Expectations/Uiop.Default.verified.txt b/src/Snitch.Tests/Expectations/Uiop.Default.verified.txt new file mode 100644 index 0000000..3f97d44 --- /dev/null +++ b/src/Snitch.Tests/Expectations/Uiop.Default.verified.txt @@ -0,0 +1,14 @@ +Analyzing... +Analyzing Uiop.csproj +Analyzing Uiop... + * Analyzing Qwerty (net8.0)... + * Analyzing Zxcv (net8.0)... + +╭─────────────────────────────────────────────────────────╮ +│ Packages that might be removed from Uiop: │ +│ ┌─────────┬─────────┬─────────────────────────────────┐ │ +│ │ Package │ Version │ Reason │ │ +│ ├─────────┼─────────┼─────────────────────────────────┤ │ +│ │ Autofac │ 4.9.3 │ Downgraded from 4.9.4 in Qwerty │ │ +│ └─────────┴─────────┴─────────────────────────────────┘ │ +╰─────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/src/Snitch.Tests/ProgramTests.cs b/src/Snitch.Tests/ProgramTests.cs index f09532d..c10aac7 100644 --- a/src/Snitch.Tests/ProgramTests.cs +++ b/src/Snitch.Tests/ProgramTests.cs @@ -235,6 +235,21 @@ public async Task Should_Return_Expected_Result_For_Slnx_Solution_Not_Specifying await Verifier.Verify(output); } + [Fact] + [Expectation("Uiop", "Default")] + public async Task Should_Prefer_An_Inline_Version_Over_The_Centrally_Managed_One() + { + // Given + var project = Fixture.GetPath("CentralizedPackageManagement/Uiop/Uiop.csproj"); + + // When + var (exitCode, output) = await Fixture.Run(project); + + // Then + exitCode.ShouldBe(0); + await Verifier.Verify(output); + } + [Fact] [Expectation("Migrating", "Default")] public async Task Should_Return_Expected_Result_When_Only_The_Referenced_Project_Uses_Central_Package_Management() From 04f8322725d97106a80edcfc00cd368c7b20a783 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Urba=C5=9B?= Date: Fri, 11 Sep 2026 19:05:05 +0200 Subject: [PATCH 13/27] fix: pick the centrally managed version of the right target framework A PackageVersion can be conditioned on the target framework, so a project targeting for example net6.0 and net8.0 can manage a different version for each. Matching the lock file target only by framework identifier picked whichever came first, reporting the other framework's version. --- .../ConditionalVersions/App/App.csproj | 15 +++++++++++++++ .../ConditionalVersions/App/Class1.cs | 6 ++++++ .../ConditionalVersions/Directory.Packages.props | 12 ++++++++++++ .../ConditionalVersions/Lib/Class1.cs | 6 ++++++ .../ConditionalVersions/Lib/Lib.csproj | 11 +++++++++++ .../Expectations/App.net6.0.verified.txt | 13 +++++++++++++ src/Snitch.Tests/ProgramTests.cs | 15 +++++++++++++++ src/Snitch/Analysis/ProjectBuilder.cs | 7 ++++++- 8 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 src/Snitch.Tests.Fixtures/ConditionalVersions/App/App.csproj create mode 100644 src/Snitch.Tests.Fixtures/ConditionalVersions/App/Class1.cs create mode 100644 src/Snitch.Tests.Fixtures/ConditionalVersions/Directory.Packages.props create mode 100644 src/Snitch.Tests.Fixtures/ConditionalVersions/Lib/Class1.cs create mode 100644 src/Snitch.Tests.Fixtures/ConditionalVersions/Lib/Lib.csproj create mode 100644 src/Snitch.Tests/Expectations/App.net6.0.verified.txt diff --git a/src/Snitch.Tests.Fixtures/ConditionalVersions/App/App.csproj b/src/Snitch.Tests.Fixtures/ConditionalVersions/App/App.csproj new file mode 100644 index 0000000..b237040 --- /dev/null +++ b/src/Snitch.Tests.Fixtures/ConditionalVersions/App/App.csproj @@ -0,0 +1,15 @@ + + + + net6.0;net8.0 + + + + + + + + + + + diff --git a/src/Snitch.Tests.Fixtures/ConditionalVersions/App/Class1.cs b/src/Snitch.Tests.Fixtures/ConditionalVersions/App/Class1.cs new file mode 100644 index 0000000..a380d0b --- /dev/null +++ b/src/Snitch.Tests.Fixtures/ConditionalVersions/App/Class1.cs @@ -0,0 +1,6 @@ +namespace App +{ + public class Class1 + { + } +} diff --git a/src/Snitch.Tests.Fixtures/ConditionalVersions/Directory.Packages.props b/src/Snitch.Tests.Fixtures/ConditionalVersions/Directory.Packages.props new file mode 100644 index 0000000..28a1964 --- /dev/null +++ b/src/Snitch.Tests.Fixtures/ConditionalVersions/Directory.Packages.props @@ -0,0 +1,12 @@ + + + + true + + + + + + + + diff --git a/src/Snitch.Tests.Fixtures/ConditionalVersions/Lib/Class1.cs b/src/Snitch.Tests.Fixtures/ConditionalVersions/Lib/Class1.cs new file mode 100644 index 0000000..0dbf33e --- /dev/null +++ b/src/Snitch.Tests.Fixtures/ConditionalVersions/Lib/Class1.cs @@ -0,0 +1,6 @@ +namespace Lib +{ + public class Class1 + { + } +} diff --git a/src/Snitch.Tests.Fixtures/ConditionalVersions/Lib/Lib.csproj b/src/Snitch.Tests.Fixtures/ConditionalVersions/Lib/Lib.csproj new file mode 100644 index 0000000..8e25652 --- /dev/null +++ b/src/Snitch.Tests.Fixtures/ConditionalVersions/Lib/Lib.csproj @@ -0,0 +1,11 @@ + + + + net6.0;net8.0 + + + + + + + diff --git a/src/Snitch.Tests/Expectations/App.net6.0.verified.txt b/src/Snitch.Tests/Expectations/App.net6.0.verified.txt new file mode 100644 index 0000000..7c214c9 --- /dev/null +++ b/src/Snitch.Tests/Expectations/App.net6.0.verified.txt @@ -0,0 +1,13 @@ +Analyzing... +Analyzing App.csproj +Analyzing App (net6.0)... + * Analyzing Lib (net6.0)... + +╭──────────────────────────────────────────────────────╮ +│ Packages that might be removed from App: │ +│ ┌─────────┬─────────┬──────────────────────────────┐ │ +│ │ Package │ Version │ Reason │ │ +│ ├─────────┼─────────┼──────────────────────────────┤ │ +│ │ Autofac │ 4.9.2 │ Downgraded from 4.9.3 in Lib │ │ +│ └─────────┴─────────┴──────────────────────────────┘ │ +╰──────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/src/Snitch.Tests/ProgramTests.cs b/src/Snitch.Tests/ProgramTests.cs index c10aac7..77050b3 100644 --- a/src/Snitch.Tests/ProgramTests.cs +++ b/src/Snitch.Tests/ProgramTests.cs @@ -235,6 +235,21 @@ public async Task Should_Return_Expected_Result_For_Slnx_Solution_Not_Specifying await Verifier.Verify(output); } + [Fact] + [Expectation("App", "net6.0")] + public async Task Should_Use_The_Centrally_Managed_Version_Of_The_Requested_Target_Framework() + { + // Given + var project = Fixture.GetPath("ConditionalVersions/App/App.csproj"); + + // When + var (exitCode, output) = await Fixture.Run(project, "--tfm", "net6.0"); + + // Then + exitCode.ShouldBe(0); + await Verifier.Verify(output); + } + [Fact] [Expectation("Uiop", "Default")] public async Task Should_Prefer_An_Inline_Version_Over_The_Centrally_Managed_One() diff --git a/src/Snitch/Analysis/ProjectBuilder.cs b/src/Snitch/Analysis/ProjectBuilder.cs index e87d6fb..941d85c 100644 --- a/src/Snitch/Analysis/ProjectBuilder.cs +++ b/src/Snitch/Analysis/ProjectBuilder.cs @@ -160,8 +160,13 @@ private static Dictionary GetRestoredVersions(string? lockFilePa var lockFile = new LockFileFormat().Read(lockFilePath); var framework = NuGetFramework.Parse(targetFramework); + + // Versions can be conditioned per target framework, so the exact one has to win + // over another one that merely shares the same framework identifier. var target = lockFile.PackageSpec.TargetFrameworks.FirstOrDefault( - x => x.FrameworkName.Framework.Equals(framework.Framework, StringComparison.OrdinalIgnoreCase)); + x => x.FrameworkName.Equals(framework)) + ?? lockFile.PackageSpec.TargetFrameworks.FirstOrDefault( + x => x.FrameworkName.Framework.Equals(framework.Framework, StringComparison.OrdinalIgnoreCase)); if (target == null) { From 8b6b93cd94a237d3309c17c17d39081d9a6ff5f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Urba=C5=9B?= Date: Fri, 11 Sep 2026 19:07:22 +0200 Subject: [PATCH 14/27] test: use the full slnx schema in the fixture Covers configurations, solution items and per project elements, so that only the actual project paths are picked up. --- .../SolutionFolders/SolutionFolders.slnx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/Snitch.Tests.Fixtures/SolutionFolders/SolutionFolders.slnx b/src/Snitch.Tests.Fixtures/SolutionFolders/SolutionFolders.slnx index 3866806..8712b2f 100644 --- a/src/Snitch.Tests.Fixtures/SolutionFolders/SolutionFolders.slnx +++ b/src/Snitch.Tests.Fixtures/SolutionFolders/SolutionFolders.slnx @@ -1,8 +1,21 @@ + + + + + + + + + - + + + - + + + From 69f16b32e4a73cc50cb0e8e55b56259e8d73b439 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Urba=C5=9B?= Date: Fri, 11 Sep 2026 19:12:53 +0200 Subject: [PATCH 15/27] test: cover slnx with --skip and an ambiguous directory A directory holding both a .sln and a .slnx is rejected, matching what the dotnet CLI does for the same directory. --- .../Expectations/Slnx.Skip_Foo.verified.txt | 19 ++++++++++++ .../Solution.Ambiguous.verified.txt | 1 + src/Snitch.Tests/ProgramTests.cs | 30 +++++++++++++++++++ 3 files changed, 50 insertions(+) create mode 100644 src/Snitch.Tests/Expectations/Slnx.Skip_Foo.verified.txt create mode 100644 src/Snitch.Tests/Expectations/Solution.Ambiguous.verified.txt diff --git a/src/Snitch.Tests/Expectations/Slnx.Skip_Foo.verified.txt b/src/Snitch.Tests/Expectations/Slnx.Skip_Foo.verified.txt new file mode 100644 index 0000000..d4c43bc --- /dev/null +++ b/src/Snitch.Tests/Expectations/Slnx.Skip_Foo.verified.txt @@ -0,0 +1,19 @@ +Analyzing... +Analyzing Snitch.Tests.Fixtures.slnx +Analyzing Bar... +Analyzing Baz... +Analyzing Qux... +Analyzing Zap... +Analyzing Quux... +Analyzing Quuux... +Analyzing Thud... +Analyzing Thuuud... + +╭─────────────────────────────────────────╮ +│ Packages that can be removed from Baz: │ +│ ┌──────────────┬──────────────────────┐ │ +│ │ Package │ Referenced by │ │ +│ ├──────────────┼──────────────────────┤ │ +│ │ Autofac │ Bar │ │ +│ └──────────────┴──────────────────────┘ │ +╰─────────────────────────────────────────╯ \ No newline at end of file diff --git a/src/Snitch.Tests/Expectations/Solution.Ambiguous.verified.txt b/src/Snitch.Tests/Expectations/Solution.Ambiguous.verified.txt new file mode 100644 index 0000000..2970356 --- /dev/null +++ b/src/Snitch.Tests/Expectations/Solution.Ambiguous.verified.txt @@ -0,0 +1 @@ +Error: More than one solution file found. \ No newline at end of file diff --git a/src/Snitch.Tests/ProgramTests.cs b/src/Snitch.Tests/ProgramTests.cs index 77050b3..188305f 100644 --- a/src/Snitch.Tests/ProgramTests.cs +++ b/src/Snitch.Tests/ProgramTests.cs @@ -310,6 +310,36 @@ public async Task Should_Return_Expected_Result_For_Slnx_Solution_With_Solution_ await Verifier.Verify(output); } + [Fact] + [Expectation("Slnx", "Skip_Foo")] + public async Task Should_Return_Expected_Result_For_Slnx_Solution_When_Skipping_A_Project() + { + // Given + var solution = Fixture.GetPath("Snitch.Tests.Fixtures.slnx"); + + // When + var (exitCode, output) = await Fixture.Run(solution, "--skip", "Foo"); + + // Then + exitCode.ShouldBe(0); + await Verifier.Verify(output); + } + + [Fact] + [Expectation("Solution", "Ambiguous")] + public async Task Should_Fail_When_A_Directory_Holds_Both_A_Sln_And_A_Slnx() + { + // Given + var directory = Fixture.GetPath(string.Empty); + + // When + var (exitCode, output) = await Fixture.Run(directory); + + // Then + exitCode.ShouldBe(-1); + await Verifier.Verify(output); + } + [Fact] [Expectation("Slnx", "Discovered")] public async Task Should_Find_A_Slnx_Solution_In_A_Directory() From 3e613636454f718839eadf4de32aaefa37915904 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Urba=C5=9B?= Date: Fri, 11 Sep 2026 19:23:29 +0200 Subject: [PATCH 16/27] fix: don't merge two projects that share a file name The dictionary of already built projects was keyed by file name, so a solution containing two different projects called Common.csproj only ever got the first one analyzed. The second reference resolved to the first project, and every package it alone provided went unreported. Key by the full path instead, which is what ProjectComparer already uses to identify a project. Project.File existed only to serve as that key and is now unused, so it goes with it. --- .../Duplicates/Consumer/Consumer.csproj | 18 ++++++++++++++++++ .../Duplicates/First/Common.csproj | 12 ++++++++++++ .../Duplicates/Second/Common.csproj | 12 ++++++++++++ .../Duplicates.Default.verified.txt | 15 +++++++++++++++ src/Snitch.Tests/ProgramTests.cs | 15 +++++++++++++++ src/Snitch/Analysis/Models/Project.cs | 2 -- src/Snitch/Analysis/ProjectBuilder.cs | 8 +++++--- 7 files changed, 77 insertions(+), 5 deletions(-) create mode 100644 src/Snitch.Tests.Fixtures/Duplicates/Consumer/Consumer.csproj create mode 100644 src/Snitch.Tests.Fixtures/Duplicates/First/Common.csproj create mode 100644 src/Snitch.Tests.Fixtures/Duplicates/Second/Common.csproj create mode 100644 src/Snitch.Tests/Expectations/Duplicates.Default.verified.txt diff --git a/src/Snitch.Tests.Fixtures/Duplicates/Consumer/Consumer.csproj b/src/Snitch.Tests.Fixtures/Duplicates/Consumer/Consumer.csproj new file mode 100644 index 0000000..479c1da --- /dev/null +++ b/src/Snitch.Tests.Fixtures/Duplicates/Consumer/Consumer.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + + + + + + + + + + + + + + diff --git a/src/Snitch.Tests.Fixtures/Duplicates/First/Common.csproj b/src/Snitch.Tests.Fixtures/Duplicates/First/Common.csproj new file mode 100644 index 0000000..b112720 --- /dev/null +++ b/src/Snitch.Tests.Fixtures/Duplicates/First/Common.csproj @@ -0,0 +1,12 @@ + + + + net8.0 + First.Common + + + + + + + diff --git a/src/Snitch.Tests.Fixtures/Duplicates/Second/Common.csproj b/src/Snitch.Tests.Fixtures/Duplicates/Second/Common.csproj new file mode 100644 index 0000000..d24c937 --- /dev/null +++ b/src/Snitch.Tests.Fixtures/Duplicates/Second/Common.csproj @@ -0,0 +1,12 @@ + + + + net8.0 + Second.Common + + + + + + + diff --git a/src/Snitch.Tests/Expectations/Duplicates.Default.verified.txt b/src/Snitch.Tests/Expectations/Duplicates.Default.verified.txt new file mode 100644 index 0000000..384f96e --- /dev/null +++ b/src/Snitch.Tests/Expectations/Duplicates.Default.verified.txt @@ -0,0 +1,15 @@ +Analyzing... +Analyzing Consumer.csproj +Analyzing Consumer... + * Analyzing Common (net8.0)... + * Analyzing Common (net8.0)... + +╭──────────────────────────────────────────────╮ +│ Packages that can be removed from Consumer: │ +│ ┌───────────────┬──────────────────────────┐ │ +│ │ Package │ Referenced by │ │ +│ ├───────────────┼──────────────────────────┤ │ +│ │ Autofac │ Common │ │ +│ │ Serilog │ Common │ │ +│ └───────────────┴──────────────────────────┘ │ +╰──────────────────────────────────────────────╯ \ No newline at end of file diff --git a/src/Snitch.Tests/ProgramTests.cs b/src/Snitch.Tests/ProgramTests.cs index 188305f..f23fce8 100644 --- a/src/Snitch.Tests/ProgramTests.cs +++ b/src/Snitch.Tests/ProgramTests.cs @@ -355,6 +355,21 @@ public async Task Should_Find_A_Slnx_Solution_In_A_Directory() await Verifier.Verify(output); } + [Fact] + [Expectation("Duplicates", "Default")] + public async Task Should_Not_Merge_Two_Referenced_Projects_That_Share_A_File_Name() + { + // Given + var project = Fixture.GetPath("Duplicates/Consumer/Consumer.csproj"); + + // When + var (exitCode, output) = await Fixture.Run(project); + + // Then + exitCode.ShouldBe(0); + await Verifier.Verify(output); + } + public sealed class Fixture { public static string GetPath(string path) diff --git a/src/Snitch/Analysis/Models/Project.cs b/src/Snitch/Analysis/Models/Project.cs index 0d02e52..b49dc6a 100644 --- a/src/Snitch/Analysis/Models/Project.cs +++ b/src/Snitch/Analysis/Models/Project.cs @@ -8,7 +8,6 @@ namespace Snitch.Analysis internal sealed class Project { public string Path { get; } - public string File { get; } public string Name { get; } public string TargetFramework { get; set; } public string? LockFilePath { get; set; } @@ -18,7 +17,6 @@ internal sealed class Project public Project(string path) { Path = path ?? throw new ArgumentNullException(nameof(path)); - File = System.IO.Path.GetFileName(Path); Name = System.IO.Path.GetFileNameWithoutExtension(Path); TargetFramework = string.Empty; ProjectReferences = new List(); diff --git a/src/Snitch/Analysis/ProjectBuilder.cs b/src/Snitch/Analysis/ProjectBuilder.cs index 941d85c..7c03ca2 100644 --- a/src/Snitch/Analysis/ProjectBuilder.cs +++ b/src/Snitch/Analysis/ProjectBuilder.cs @@ -26,7 +26,9 @@ public ProjectBuildResult Build( IEnumerable? cache = null) { var manager = new AnalyzerManager(); - var built = cache?.ToDictionary(x => x.File, x => x, StringComparer.OrdinalIgnoreCase) + + // Keyed by the full path — two projects can share a file name. + var built = cache?.ToDictionary(x => x.Path, x => x, StringComparer.OrdinalIgnoreCase) ?? new Dictionary(StringComparer.OrdinalIgnoreCase); var project = Build(manager, path, tfm, skip, built); @@ -59,7 +61,7 @@ private Project Build( path = Path.GetFullPath(path); // Already built this project? - if (built.TryGetValue(Path.GetFileName(path), out var project)) + if (built.TryGetValue(path, out var project)) { return project; } @@ -98,7 +100,7 @@ private Project Build( project.LockFilePath = assetPath; // Add the project to the built list. - built.Add(Path.GetFileName(path), project); + built.Add(path, project); // Get the package references. var restoredVersions = GetRestoredVersions(assetPath, project.TargetFramework); From 78482201d0d9f82695af24e71cdf7fc3c59ddbfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Urba=C5=9B?= Date: Fri, 11 Sep 2026 19:23:36 +0200 Subject: [PATCH 17/27] fix: match the lock file target and the root project exactly PruneResults picked the first lock file target sharing the requested framework identifier, so for a multi targeting project net6.0 could be pruned against net8.0's dependencies and auto referenced or private packages would survive into the report. Prefer an exact framework match and keep the identifier match as a fallback, mirroring ProjectBuilder. Findings were also attributed to the root project by name, which is ambiguous now that two projects may share one. --- src/Snitch/Analysis/ProjectAnalyzer.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Snitch/Analysis/ProjectAnalyzer.cs b/src/Snitch/Analysis/ProjectAnalyzer.cs index e2e28c8..68c6999 100644 --- a/src/Snitch/Analysis/ProjectAnalyzer.cs +++ b/src/Snitch/Analysis/ProjectAnalyzer.cs @@ -61,7 +61,7 @@ private List AnalyzeProject(Project root, Project project, List< { if (!result.ContainsPackage(found.Package)) { - if (project.Name.Equals(root.Name, StringComparison.OrdinalIgnoreCase)) + if (project.Path.Equals(root.Path, StringComparison.OrdinalIgnoreCase)) { result.Add(new PackageToRemove(project, package, found)); } @@ -105,8 +105,13 @@ private static List PruneResults(Project project, List x.FrameworkName.Framework.Equals(framework.Framework, StringComparison.OrdinalIgnoreCase)); + x => x.FrameworkName.Equals(framework)) + ?? lockfile.PackageSpec.TargetFrameworks.FirstOrDefault( + x => x.FrameworkName.Framework.Equals(framework.Framework, StringComparison.OrdinalIgnoreCase)); // Could we not find the target? if (target == null) From 9626c43bce23113c231a1cd3c4e282b12151136f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Urba=C5=9B?= Date: Fri, 11 Sep 2026 19:32:10 +0200 Subject: [PATCH 18/27] fix: honour PrivateAssets="all" on a referenced project's packages A package a referenced project declares with PrivateAssets="all" does not flow to the projects referencing it, so the consumer has to declare it itself. The check for that only looked for the literal "compile", which "all" does not contain, so those packages were accumulated as provided by the child and the consumer's own reference was reported as removable. Removing it would have broken the build. Move the test onto Package.IsPrivate, which splits the metadata and accepts "all" as well as "compile", case insensitively. --- .../PrivateAssets/Hidden/Hidden.csproj | 12 ++++++++++++ .../PrivateAssets/Visible/Visible.csproj | 15 +++++++++++++++ .../Expectations/PrivateAssets.All.verified.txt | 6 ++++++ src/Snitch.Tests/ProgramTests.cs | 15 +++++++++++++++ src/Snitch/Analysis/Models/Package.cs | 11 +++++++++++ src/Snitch/Analysis/ProjectAnalyzer.cs | 2 +- 6 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 src/Snitch.Tests.Fixtures/PrivateAssets/Hidden/Hidden.csproj create mode 100644 src/Snitch.Tests.Fixtures/PrivateAssets/Visible/Visible.csproj create mode 100644 src/Snitch.Tests/Expectations/PrivateAssets.All.verified.txt diff --git a/src/Snitch.Tests.Fixtures/PrivateAssets/Hidden/Hidden.csproj b/src/Snitch.Tests.Fixtures/PrivateAssets/Hidden/Hidden.csproj new file mode 100644 index 0000000..57321a2 --- /dev/null +++ b/src/Snitch.Tests.Fixtures/PrivateAssets/Hidden/Hidden.csproj @@ -0,0 +1,12 @@ + + + + net8.0 + + + + + + + + diff --git a/src/Snitch.Tests.Fixtures/PrivateAssets/Visible/Visible.csproj b/src/Snitch.Tests.Fixtures/PrivateAssets/Visible/Visible.csproj new file mode 100644 index 0000000..1283eb2 --- /dev/null +++ b/src/Snitch.Tests.Fixtures/PrivateAssets/Visible/Visible.csproj @@ -0,0 +1,15 @@ + + + + net8.0 + + + + + + + + + + + diff --git a/src/Snitch.Tests/Expectations/PrivateAssets.All.verified.txt b/src/Snitch.Tests/Expectations/PrivateAssets.All.verified.txt new file mode 100644 index 0000000..abae9ee --- /dev/null +++ b/src/Snitch.Tests/Expectations/PrivateAssets.All.verified.txt @@ -0,0 +1,6 @@ +Analyzing... +Analyzing Visible.csproj +Analyzing Visible... + * Analyzing Hidden (net8.0)... + +Everything looks good! \ No newline at end of file diff --git a/src/Snitch.Tests/ProgramTests.cs b/src/Snitch.Tests/ProgramTests.cs index f23fce8..82246d1 100644 --- a/src/Snitch.Tests/ProgramTests.cs +++ b/src/Snitch.Tests/ProgramTests.cs @@ -370,6 +370,21 @@ public async Task Should_Not_Merge_Two_Referenced_Projects_That_Share_A_File_Nam await Verifier.Verify(output); } + [Fact] + [Expectation("PrivateAssets", "All")] + public async Task Should_Not_Report_A_Package_A_Referenced_Project_Keeps_Private() + { + // Given + var project = Fixture.GetPath("PrivateAssets/Visible/Visible.csproj"); + + // When + var (exitCode, output) = await Fixture.Run(project); + + // Then + exitCode.ShouldBe(0); + await Verifier.Verify(output); + } + public sealed class Fixture { public static string GetPath(string path) diff --git a/src/Snitch/Analysis/Models/Package.cs b/src/Snitch/Analysis/Models/Package.cs index 0733492..c7cd017 100644 --- a/src/Snitch/Analysis/Models/Package.cs +++ b/src/Snitch/Analysis/Models/Package.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics; +using System.Linq; using NuGet.Versioning; namespace Snitch.Analysis @@ -13,6 +14,16 @@ internal sealed class Package public string? PrivateAssets { get; } + /// + /// Gets a value indicating whether the compile time assets of this package + /// are kept from projects referencing the one declaring it. + /// + public bool IsPrivate => PrivateAssets != null && PrivateAssets + .Split(new[] { ';', ',' }, StringSplitOptions.RemoveEmptyEntries) + .Any(asset => + asset.Trim().Equals("compile", StringComparison.OrdinalIgnoreCase) || + asset.Trim().Equals("all", StringComparison.OrdinalIgnoreCase)); + public bool IsGreaterThan(Package package, out bool indeterminate) { indeterminate = true; diff --git a/src/Snitch/Analysis/ProjectAnalyzer.cs b/src/Snitch/Analysis/ProjectAnalyzer.cs index 68c6999..96a7328 100644 --- a/src/Snitch/Analysis/ProjectAnalyzer.cs +++ b/src/Snitch/Analysis/ProjectAnalyzer.cs @@ -86,7 +86,7 @@ private List AnalyzeProject(Project root, Project project, List< void AddToAccumulated(Package package) { - if (package.PrivateAssets != null && package.PrivateAssets.Contains("compile")) + if (package.IsPrivate) { return; } From dc841dfd269bc4475e75244ffd85bcc859fc12bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Urba=C5=9B?= Date: Fri, 11 Sep 2026 19:32:22 +0200 Subject: [PATCH 19/27] fix: apply --exclude to the pre-release report as well Filter only rebuilt the list of packages to remove; the pre-release list was recomputed from the project, so an excluded package still showed up under --no-prerelease and still failed --strict with exit code 255. Pass the exclusions into the result so they apply to both. --- .../Thuuud.Exclude_NoPreRelease.verified.txt | 6 ++++++ src/Snitch.Tests/ProgramTests.cs | 15 +++++++++++++++ src/Snitch/Analysis/ProjectAnalyzerResult.cs | 11 ++++++++--- 3 files changed, 29 insertions(+), 3 deletions(-) create mode 100644 src/Snitch.Tests/Expectations/Thuuud.Exclude_NoPreRelease.verified.txt diff --git a/src/Snitch.Tests/Expectations/Thuuud.Exclude_NoPreRelease.verified.txt b/src/Snitch.Tests/Expectations/Thuuud.Exclude_NoPreRelease.verified.txt new file mode 100644 index 0000000..3639bf7 --- /dev/null +++ b/src/Snitch.Tests/Expectations/Thuuud.Exclude_NoPreRelease.verified.txt @@ -0,0 +1,6 @@ +Analyzing... +Analyzing Thuuud.csproj +Analyzing Thuuud... + * Analyzing Foo (netstandard2.0)... + +Everything looks good! \ No newline at end of file diff --git a/src/Snitch.Tests/ProgramTests.cs b/src/Snitch.Tests/ProgramTests.cs index 82246d1..5bdd899 100644 --- a/src/Snitch.Tests/ProgramTests.cs +++ b/src/Snitch.Tests/ProgramTests.cs @@ -370,6 +370,21 @@ public async Task Should_Not_Merge_Two_Referenced_Projects_That_Share_A_File_Nam await Verifier.Verify(output); } + [Fact] + [Expectation("Thuuud", "Exclude_NoPreRelease")] + public async Task Should_Not_Report_An_Excluded_Package_As_A_Pre_Release() + { + // Given + var project = Fixture.GetPath("Thuuud/Thuuud.csproj"); + + // When + var (exitCode, output) = await Fixture.Run(project, "--no-prerelease", "--strict", "--exclude", "Newtonsoft.Json"); + + // Then + exitCode.ShouldBe(0); + await Verifier.Verify(output); + } + [Fact] [Expectation("PrivateAssets", "All")] public async Task Should_Not_Report_A_Package_A_Referenced_Project_Keeps_Private() diff --git a/src/Snitch/Analysis/ProjectAnalyzerResult.cs b/src/Snitch/Analysis/ProjectAnalyzerResult.cs index 8a6ac39..ebff57c 100644 --- a/src/Snitch/Analysis/ProjectAnalyzerResult.cs +++ b/src/Snitch/Analysis/ProjectAnalyzerResult.cs @@ -18,14 +18,19 @@ internal sealed class ProjectAnalyzerResult public bool HasPreReleases => PreReleasePackages.Count > 0; - public ProjectAnalyzerResult(Project project, IEnumerable packages) + public ProjectAnalyzerResult(Project project, IEnumerable packages, string[]? exclude = null) { _project = project; _packages = new List(packages ?? throw new ArgumentNullException(nameof(packages))); CanBeRemoved = new List(packages.Where(p => p.CanBeRemoved)); MightBeRemoved = new List(packages.Where(p => p.VersionMismatch)); - PreReleasePackages = new List(project.Packages.Where(p => p.Version != null && p.Version.IsPrerelease)); + + // An excluded package is excluded everywhere, the pre-release report included. + PreReleasePackages = new List(project.Packages.Where( + p => p.Version != null + && p.Version.IsPrerelease + && !(exclude?.Contains(p.Name, StringComparer.OrdinalIgnoreCase) ?? false))); } public ProjectAnalyzerResult Filter(string[]? packages) @@ -36,7 +41,7 @@ public ProjectAnalyzerResult Filter(string[]? packages) } var filtered = _packages.Where(p => !packages.Contains(p.Package.Name, StringComparer.OrdinalIgnoreCase)); - return new ProjectAnalyzerResult(_project, filtered); + return new ProjectAnalyzerResult(_project, filtered, packages); } } } \ No newline at end of file From 59c4a5bbfd8e3fde89bfb04aa0ed0bdc7efd4020 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Urba=C5=9B?= Date: Fri, 11 Sep 2026 19:32:31 +0200 Subject: [PATCH 20/27] fix: discover an F# project when probing a directory Pointing snitch at a directory holding a lone .fsproj failed with "No project or solution file found." even though snitch analyzes F# projects happily when given the path explicitly. Probe for *.fsproj alongside *.csproj. --- .../Expectations/FSharp.Discovered.verified.txt | 14 ++++++++++++++ src/Snitch.Tests/ProgramTests.cs | 15 +++++++++++++++ src/Snitch/Analysis/Utilities/PathUtility.cs | 3 ++- 3 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 src/Snitch.Tests/Expectations/FSharp.Discovered.verified.txt diff --git a/src/Snitch.Tests/Expectations/FSharp.Discovered.verified.txt b/src/Snitch.Tests/Expectations/FSharp.Discovered.verified.txt new file mode 100644 index 0000000..1a1a84e --- /dev/null +++ b/src/Snitch.Tests/Expectations/FSharp.Discovered.verified.txt @@ -0,0 +1,14 @@ +Analyzing... +Analyzing FSharp.fsproj +Analyzing FSharp... + * Analyzing Bar (netstandard2.0)... + * Analyzing Foo (netstandard2.0)... + +╭────────────────────────────────────────────╮ +│ Packages that can be removed from FSharp: │ +│ ┌───────────────┬────────────────────────┐ │ +│ │ Package │ Referenced by │ │ +│ ├───────────────┼────────────────────────┤ │ +│ │ Autofac │ Foo │ │ +│ └───────────────┴────────────────────────┘ │ +╰────────────────────────────────────────────╯ \ No newline at end of file diff --git a/src/Snitch.Tests/ProgramTests.cs b/src/Snitch.Tests/ProgramTests.cs index 5bdd899..b7c0c99 100644 --- a/src/Snitch.Tests/ProgramTests.cs +++ b/src/Snitch.Tests/ProgramTests.cs @@ -400,6 +400,21 @@ public async Task Should_Not_Report_A_Package_A_Referenced_Project_Keeps_Private await Verifier.Verify(output); } + [Fact] + [Expectation("FSharp", "Discovered")] + public async Task Should_Find_An_FSharp_Project_In_A_Directory() + { + // Given + var directory = Fixture.GetPath("FSharp"); + + // When + var (exitCode, output) = await Fixture.Run(directory); + + // Then + exitCode.ShouldBe(0); + await Verifier.Verify(output); + } + public sealed class Fixture { public static string GetPath(string path) diff --git a/src/Snitch/Analysis/Utilities/PathUtility.cs b/src/Snitch/Analysis/Utilities/PathUtility.cs index 18cc215..8588e2c 100644 --- a/src/Snitch/Analysis/Utilities/PathUtility.cs +++ b/src/Snitch/Analysis/Utilities/PathUtility.cs @@ -70,7 +70,8 @@ private static List FindProjects(string? root, out string entry) if (slns.Length == 0) { - var subProjects = Directory.GetFiles(root, "*.csproj"); + // F# projects are analyzed just like C# ones, so discover them too. + var subProjects = Directory.GetFiles(root, "*.csproj").Concat(Directory.GetFiles(root, "*.fsproj")).ToArray(); if (subProjects.Length == 0) { throw new InvalidOperationException("No project or solution file found."); From 387f6c8ef1f8524e67e2e9f9cb96d86a15e2f817 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Urba=C5=9B?= Date: Fri, 11 Sep 2026 19:42:10 +0200 Subject: [PATCH 21/27] ci: fix the stray brace in the runner label runs-on was "ubuntu-latest}", which is not a label any runner carries, so the build job could never be scheduled. --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b9668dc..81205d5 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -12,7 +12,7 @@ jobs: build: name: Build if: "!contains(github.event.head_commit.message, 'skip-ci')" - runs-on: ubuntu-latest} + runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 From f83eecb47c8f8e1e19ad3a0d495ace02cc9343ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Urba=C5=9B?= Date: Fri, 11 Sep 2026 20:11:23 +0200 Subject: [PATCH 22/27] fix: escape user supplied text before writing it as markup Package names, project names and versions were interpolated straight into Spectre.Console markup. A pinned version such as [3.5.4] is also a valid markup tag, so the run died with "Could not find color or style '3.5.4'". --- .../Pinned/App/App.csproj | 15 ++++ .../Pinned/App/Class1.cs | 6 ++ .../Pinned/Lib/Class1.cs | 6 ++ .../Pinned/Lib/Lib.csproj | 11 +++ .../Expectations/Pinned.Default.verified.txt | 13 +++ src/Snitch.Tests/ProgramTests.cs | 15 ++++ src/Snitch.Tests/ProjectReporterTests.cs | 88 +++++++++++++++++++ src/Snitch/Analysis/ProjectBuilder.cs | 8 +- src/Snitch/Analysis/ProjectReporter.cs | 30 +++---- src/Snitch/Commands/AnalyzeCommand.cs | 2 +- 10 files changed, 174 insertions(+), 20 deletions(-) create mode 100644 src/Snitch.Tests.Fixtures/Pinned/App/App.csproj create mode 100644 src/Snitch.Tests.Fixtures/Pinned/App/Class1.cs create mode 100644 src/Snitch.Tests.Fixtures/Pinned/Lib/Class1.cs create mode 100644 src/Snitch.Tests.Fixtures/Pinned/Lib/Lib.csproj create mode 100644 src/Snitch.Tests/Expectations/Pinned.Default.verified.txt create mode 100644 src/Snitch.Tests/ProjectReporterTests.cs diff --git a/src/Snitch.Tests.Fixtures/Pinned/App/App.csproj b/src/Snitch.Tests.Fixtures/Pinned/App/App.csproj new file mode 100644 index 0000000..7f80753 --- /dev/null +++ b/src/Snitch.Tests.Fixtures/Pinned/App/App.csproj @@ -0,0 +1,15 @@ + + + + netstandard2.0 + + + + + + + + + + + diff --git a/src/Snitch.Tests.Fixtures/Pinned/App/Class1.cs b/src/Snitch.Tests.Fixtures/Pinned/App/Class1.cs new file mode 100644 index 0000000..aeeacc4 --- /dev/null +++ b/src/Snitch.Tests.Fixtures/Pinned/App/Class1.cs @@ -0,0 +1,6 @@ +namespace Pinned.App +{ + public class Class1 + { + } +} diff --git a/src/Snitch.Tests.Fixtures/Pinned/Lib/Class1.cs b/src/Snitch.Tests.Fixtures/Pinned/Lib/Class1.cs new file mode 100644 index 0000000..2128e8a --- /dev/null +++ b/src/Snitch.Tests.Fixtures/Pinned/Lib/Class1.cs @@ -0,0 +1,6 @@ +namespace Pinned.Lib +{ + public class Class1 + { + } +} diff --git a/src/Snitch.Tests.Fixtures/Pinned/Lib/Lib.csproj b/src/Snitch.Tests.Fixtures/Pinned/Lib/Lib.csproj new file mode 100644 index 0000000..fe8e59c --- /dev/null +++ b/src/Snitch.Tests.Fixtures/Pinned/Lib/Lib.csproj @@ -0,0 +1,11 @@ + + + + netstandard2.0 + + + + + + + diff --git a/src/Snitch.Tests/Expectations/Pinned.Default.verified.txt b/src/Snitch.Tests/Expectations/Pinned.Default.verified.txt new file mode 100644 index 0000000..04a876d --- /dev/null +++ b/src/Snitch.Tests/Expectations/Pinned.Default.verified.txt @@ -0,0 +1,13 @@ +Analyzing... +Analyzing App.csproj +Analyzing App... + * Analyzing Lib (netstandard2.0)... + +╭─────────────────────────────────────────────────────╮ +│ Packages that might be removed from App: │ +│ ┌─────────┬─────────┬─────────────────────────────┐ │ +│ │ Package │ Version │ Reason │ │ +│ ├─────────┼─────────┼─────────────────────────────┤ │ +│ │ Autofac │ [4.9.4] │ Updated from [4.9.3] in Lib │ │ +│ └─────────┴─────────┴─────────────────────────────┘ │ +╰─────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/src/Snitch.Tests/ProgramTests.cs b/src/Snitch.Tests/ProgramTests.cs index b7c0c99..27f9c4d 100644 --- a/src/Snitch.Tests/ProgramTests.cs +++ b/src/Snitch.Tests/ProgramTests.cs @@ -415,6 +415,21 @@ public async Task Should_Find_An_FSharp_Project_In_A_Directory() await Verifier.Verify(output); } + [Fact] + [Expectation("Pinned", "Default")] + public async Task Should_Not_Interpret_A_Pinned_Package_Version_As_Markup() + { + // Given + var project = Fixture.GetPath("Pinned/App/App.csproj"); + + // When + var (exitCode, output) = await Fixture.Run(project); + + // Then + exitCode.ShouldBe(0); + await Verifier.Verify(output); + } + public sealed class Fixture { public static string GetPath(string path) diff --git a/src/Snitch.Tests/ProjectReporterTests.cs b/src/Snitch.Tests/ProjectReporterTests.cs new file mode 100644 index 0000000..9c0b448 --- /dev/null +++ b/src/Snitch.Tests/ProjectReporterTests.cs @@ -0,0 +1,88 @@ +using System.Collections.Generic; +using Shouldly; +using Snitch.Analysis; +using Spectre.Console.Testing; +using Xunit; + +namespace Sntich.Tests +{ + public sealed class ProjectReporterTests + { + // A pinned version such as [3.5.4] is also valid Spectre.Console markup, + // as is a project or package name containing square brackets. + private const string PinnedVersion = "[3.5.4]"; + + private static Project CreateProject(string name, params Package[] packages) + { + var project = new Project($"/tmp/{name}/{name}.csproj"); + project.TargetFramework = "netstandard2.0"; + project.Packages.AddRange(packages); + return project; + } + + private static (TestConsole Console, ProjectReporter Reporter) CreateReporter() + { + var console = new TestConsole().Width(200); + return (console, new ProjectReporter(console)); + } + + [Fact] + public void Should_Render_A_Pinned_Version_Verbatim_When_A_Package_Might_Be_Removed() + { + // Given + var (console, reporter) = CreateReporter(); + var package = new Package("RavenDB.Client", "[3.6.1]", null); + var originalPackage = new Package("RavenDB.Client", PinnedVersion, null); + var original = CreateProject("Bar", originalPackage); + var root = CreateProject("Foo", package); + var result = new ProjectAnalyzerResult( + root, + new[] { new PackageToRemove(root, package, new ProjectPackage(original, originalPackage)) }); + + // When + reporter.WriteToConsole(new List { result }, false); + + // Then + console.Output.ShouldContain("[3.6.1]"); + console.Output.ShouldContain("[3.5.4]"); + } + + [Fact] + public void Should_Not_Interpret_A_Package_Or_Project_Name_As_Markup() + { + // Given + var (console, reporter) = CreateReporter(); + var package = new Package("Red[Package]", "1.0.0", null); + var original = CreateProject("Green[Project]", package); + var root = CreateProject("Blue[Root]", package); + var result = new ProjectAnalyzerResult( + root, + new[] { new PackageToRemove(root, package, new ProjectPackage(original, package)) }); + + // When + reporter.WriteToConsole(new List { result }, false); + + // Then + console.Output.ShouldContain("Red[Package]"); + console.Output.ShouldContain("Green[Project]"); + console.Output.ShouldContain("Blue[Root]"); + } + + [Fact] + public void Should_Not_Interpret_A_Pre_Release_Version_As_Markup() + { + // Given + var (console, reporter) = CreateReporter(); + var package = new Package("Thud[Alpha]", "1.0.0-alpha", null); + var root = CreateProject("Foo", package); + var result = new ProjectAnalyzerResult(root, new PackageToRemove[0]); + + // When + reporter.WriteToConsole(new List { result }, true); + + // Then + console.Output.ShouldContain("Thud[Alpha]"); + console.Output.ShouldContain("1.0.0-alpha"); + } + } +} diff --git a/src/Snitch/Analysis/ProjectBuilder.cs b/src/Snitch/Analysis/ProjectBuilder.cs index 7c03ca2..45ea536 100644 --- a/src/Snitch/Analysis/ProjectBuilder.cs +++ b/src/Snitch/Analysis/ProjectBuilder.cs @@ -136,8 +136,8 @@ private Project Build( if (!projectReferencePath.EndsWith("csproj", StringComparison.OrdinalIgnoreCase) && !projectReferencePath.EndsWith("fsproj", StringComparison.OrdinalIgnoreCase)) { _console.MarkupLine(string.IsNullOrWhiteSpace(tfm) - ? $"Skipping Non .NET Project [aqua]{project.Name}[/]" - : $"Skipping Non .NET Project [aqua]{project.Name}[/] [grey] ({tfm})[/]"); + ? $"Skipping Non .NET Project [aqua]{project.Name.EscapeMarkup()}[/]" + : $"Skipping Non .NET Project [aqua]{project.Name.EscapeMarkup()}[/] [grey] ({tfm.EscapeMarkup()})[/]"); _console.WriteLine(); @@ -196,8 +196,8 @@ private static Dictionary GetRestoredVersions(string? lockFilePa } var status = string.IsNullOrWhiteSpace(tfm) - ? $"{prefix}Analyzing [aqua]{project.Name}[/]..." - : $"{prefix}Analyzing [aqua]{project.Name}[/] [grey]({tfm})[/]..."; + ? $"{prefix}Analyzing [aqua]{project.Name.EscapeMarkup()}[/]..." + : $"{prefix}Analyzing [aqua]{project.Name.EscapeMarkup()}[/] [grey]({tfm.EscapeMarkup()})[/]..."; _console.MarkupLine(status); diff --git a/src/Snitch/Analysis/ProjectReporter.cs b/src/Snitch/Analysis/ProjectReporter.cs index 5895abe..865f93c 100644 --- a/src/Snitch/Analysis/ProjectReporter.cs +++ b/src/Snitch/Analysis/ProjectReporter.cs @@ -42,11 +42,11 @@ public void WriteToConsole([NotNull] List results, bool n foreach (var item in result.CanBeRemoved) { table.AddRow( - $"[green]{item.Package.Name}[/]", - $"[aqua]{item.Original.Project.Name}[/]"); + $"[green]{item.Package.Name.EscapeMarkup()}[/]", + $"[aqua]{item.Original.Project.Name.EscapeMarkup()}[/]"); } - report.AddRow($" [yellow]Packages that can be removed from[/] [aqua]{result.Project}[/]:"); + report.AddRow($" [yellow]Packages that can be removed from[/] [aqua]{result.Project.EscapeMarkup()}[/]:"); report.AddRow(table); if (!last || (last && resultsWithPackageMayBeRemove.Count > 0)) @@ -67,31 +67,31 @@ public void WriteToConsole([NotNull] List results, bool n { if (item.Package.IsGreaterThan(item.Original.Package, out var indeterminate)) { - var name = item.Original.Project.Name; - var version = item.Original.Package.GetVersionString(); + var name = item.Original.Project.Name.EscapeMarkup(); + var version = item.Original.Package.GetVersionString().EscapeMarkup(); var verb = indeterminate ? "Might be updated from" : "Updated from"; var reason = $"[grey]{verb}[/] [silver]{version}[/] [grey]in[/] [aqua]{name}[/]"; table.AddRow( - $"[green]{item.Package.Name}[/]", - item.Package.GetVersionString(), + $"[green]{item.Package.Name.EscapeMarkup()}[/]", + item.Package.GetVersionString().EscapeMarkup(), reason); } else { - var name = item.Original.Project.Name; - var version = item.Original.Package.GetVersionString(); + var name = item.Original.Project.Name.EscapeMarkup(); + var version = item.Original.Package.GetVersionString().EscapeMarkup(); var verb = indeterminate ? "Does not match" : "Downgraded from"; var reason = $"[grey]{verb}[/] [silver]{version}[/] [grey]in[/] [aqua]{name}[/]"; table.AddRow( - $"[green]{item.Package.Name}[/]", - item.Package.GetVersionString(), + $"[green]{item.Package.Name.EscapeMarkup()}[/]", + item.Package.GetVersionString().EscapeMarkup(), reason); } } - report.AddRow($" [yellow]Packages that [u]might[/] be removed from[/] [aqua]{result.Project}[/]:"); + report.AddRow($" [yellow]Packages that [u]might[/] be removed from[/] [aqua]{result.Project.EscapeMarkup()}[/]:"); report.AddRow(table); if (!last) @@ -119,9 +119,9 @@ public void WriteToConsole([NotNull] List results, bool n foreach (var item in packagesByProject) { table.AddRow( - $"[green]{item.Project}[/]", - $"[yellow]{item.PackageName}[/]", - $"{item.Version}"); + $"[green]{item.Project.EscapeMarkup()}[/]", + $"[yellow]{item.PackageName.EscapeMarkup()}[/]", + $"{item.Version}".EscapeMarkup()); } report.AddRow(table); diff --git a/src/Snitch/Commands/AnalyzeCommand.cs b/src/Snitch/Commands/AnalyzeCommand.cs index 3457e46..e7c17ee 100644 --- a/src/Snitch/Commands/AnalyzeCommand.cs +++ b/src/Snitch/Commands/AnalyzeCommand.cs @@ -75,7 +75,7 @@ public override int Execute([NotNull] CommandContext context, [NotNull] Settings { ctx.Refresh(); - _console.MarkupLine($"Analyzing [yellow]{Path.GetFileName(entry)}[/]"); + _console.MarkupLine($"Analyzing [yellow]{Path.GetFileName(entry).EscapeMarkup()}[/]"); foreach (var projectToAnalyze in projectsToAnalyze) { From 923d012c824f227f0c9e44b5c315cf9e0c7cda35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Urba=C5=9B?= Date: Fri, 11 Sep 2026 20:24:11 +0200 Subject: [PATCH 23/27] fix: treat an exact version and the range it implies as the same A reference declaring '1.0.0' next to one declaring '[1.0.0, )' ended up in "might be removed", because the two parse into different fields and neither equality branch applied. Both sides are now compared as ranges. Also restores the error for a version that is neither, which the Central Package Management fix had turned into a silently version-less package. --- src/Snitch.Tests/PackageTests.cs | 39 +++++++++++++++++++++++ src/Snitch/Analysis/Models/Package.cs | 45 ++++++++++++++++++--------- 2 files changed, 69 insertions(+), 15 deletions(-) diff --git a/src/Snitch.Tests/PackageTests.cs b/src/Snitch.Tests/PackageTests.cs index 1215582..64500c4 100644 --- a/src/Snitch.Tests/PackageTests.cs +++ b/src/Snitch.Tests/PackageTests.cs @@ -1,3 +1,4 @@ +using System; using Shouldly; using Snitch.Analysis; using Xunit; @@ -64,5 +65,43 @@ public void Should_Not_Consider_A_Versionless_Package_The_Same_As_A_Versioned_On // When, Then versionless.IsSameVersion(versioned).ShouldBeFalse(); } + + [Theory] + [InlineData("1.0.0", "[1.0.0, )")] + [InlineData("[1.0.0, )", "1.0.0")] + public void Should_Consider_An_Exact_Version_The_Same_As_The_Range_It_Implies(string first, string second) + { + // Given + var left = new Package("Autofac", first, null); + var right = new Package("Autofac", second, null); + + // When, Then + left.IsSameVersion(right).ShouldBeTrue(); + } + + [Theory] + [InlineData("1.0.0", "[1.0.0]")] + [InlineData("1.0.0", "(1.0.0, )")] + [InlineData("1.0.0", "2.0.0")] + public void Should_Not_Consider_Different_Version_Ranges_To_Be_The_Same_Version(string first, string second) + { + // Given + var left = new Package("Autofac", first, null); + var right = new Package("Autofac", second, null); + + // When, Then + left.IsSameVersion(right).ShouldBeFalse(); + } + + [Fact] + public void Should_Throw_For_A_Package_With_An_Invalid_Version() + { + // Given, When + var result = Record.Exception(() => new Package("Autofac", "not-a-version", null)); + + // Then + result.ShouldBeOfType() + .Message.ShouldStartWith("Version 'not-a-version' for package 'Autofac' is not valid."); + } } } diff --git a/src/Snitch/Analysis/Models/Package.cs b/src/Snitch/Analysis/Models/Package.cs index c7cd017..feca2c2 100644 --- a/src/Snitch/Analysis/Models/Package.cs +++ b/src/Snitch/Analysis/Models/Package.cs @@ -56,23 +56,24 @@ public bool IsGreaterThan(Package package, out bool indeterminate) public bool IsSameVersion(Package package) { - if (Version == null && Range == null && package.Version == null && package.Range == null) + // An exact version is the same thing as the range it implies, so comparing + // both sides as ranges also covers a reference declaring '1.0.0' next to + // one declaring '[1.0.0, )'. + var left = AsRange(); + var right = package.AsRange(); + + if (left == null && right == null) { // Neither reference carries a version, so a central one governs both. return true; } - else if (Version != null && package.Version != null) - { - // Version == Version - return new VersionComparer().Equals(Version, package.Version); - } - else if (Range != null && package.Range != null) + + if (left == null || right == null) { - // Range == Range - return new VersionRangeComparer().Equals(Range, package.Range); + return false; } - return false; + return new VersionRangeComparer().Equals(left, right); } public string GetVersionString() @@ -90,7 +91,14 @@ public Package(string name, string? version, string? privateAssets) Name = name ?? throw new ArgumentNullException(nameof(name)); PrivateAssets = privateAssets; - if (NuGetVersion.TryParse(version, out var semanticVersion)) + if (string.IsNullOrWhiteSpace(version)) + { + // No version at all. Central Package Management leaves the version + // off the reference itself, so this is expected rather than an error. + Version = null; + Range = null; + } + else if (NuGetVersion.TryParse(version, out var semanticVersion)) { Version = semanticVersion; Range = null; @@ -102,11 +110,18 @@ public Package(string name, string? version, string? privateAssets) } else { - // No version at all. Central Package Management leaves the version - // off the reference itself, so this is expected rather than an error. - Version = null; - Range = null; + throw new ArgumentException($"Version '{version}' for package '{name}' is not valid.", nameof(version)); } } + + private VersionRange? AsRange() + { + if (Range != null) + { + return Range; + } + + return Version != null ? new VersionRange(Version) : null; + } } } \ No newline at end of file From 738308e2500ec94953f12db2526ee0fba661acfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Urba=C5=9B?= Date: Fri, 11 Sep 2026 20:48:37 +0200 Subject: [PATCH 24/27] fix: don't reuse a project across target frameworks The build cache was keyed by path alone, while a project carries the packages and lock file of the framework it was built for. Two root projects targeting different frameworks and referencing the same multi targeting project got whichever one was built first, so the second was analyzed against the wrong set of packages. The key is now the path plus the requested framework, and a project is also remembered under the framework it resolved to, so asking for that framework explicitly still hits the cache. A project asked for with no framework in particular can no longer be served from another framework's build, which costs one extra design time build where a root is also referenced by a root. --- .../SharedCache/Lib/Class1.cs | 6 +++ .../SharedCache/Lib/Lib.csproj | 12 ++++++ .../SharedCache/New/Class1.cs | 6 +++ .../SharedCache/New/New.csproj | 16 ++++++++ .../SharedCache/Old/Class1.cs | 6 +++ .../SharedCache/Old/Old.csproj | 16 ++++++++ .../SharedCache/SharedCache.slnx | 4 ++ .../Expectations/Asdf.Default.verified.txt | 1 + .../Asdf.Strict_NoPreRelease.verified.txt | 1 + .../SharedCache.Default.verified.txt | 15 ++++++++ src/Snitch.Tests/ProgramTests.cs | 15 ++++++++ src/Snitch/Analysis/Models/Project.cs | 10 ++++- src/Snitch/Analysis/Models/ProjectComparer.cs | 12 +++++- src/Snitch/Analysis/ProjectBuilder.cs | 38 +++++++++++++++---- 14 files changed, 148 insertions(+), 10 deletions(-) create mode 100644 src/Snitch.Tests.Fixtures/SharedCache/Lib/Class1.cs create mode 100644 src/Snitch.Tests.Fixtures/SharedCache/Lib/Lib.csproj create mode 100644 src/Snitch.Tests.Fixtures/SharedCache/New/Class1.cs create mode 100644 src/Snitch.Tests.Fixtures/SharedCache/New/New.csproj create mode 100644 src/Snitch.Tests.Fixtures/SharedCache/Old/Class1.cs create mode 100644 src/Snitch.Tests.Fixtures/SharedCache/Old/Old.csproj create mode 100644 src/Snitch.Tests.Fixtures/SharedCache/SharedCache.slnx create mode 100644 src/Snitch.Tests/Expectations/SharedCache.Default.verified.txt diff --git a/src/Snitch.Tests.Fixtures/SharedCache/Lib/Class1.cs b/src/Snitch.Tests.Fixtures/SharedCache/Lib/Class1.cs new file mode 100644 index 0000000..4c0f28a --- /dev/null +++ b/src/Snitch.Tests.Fixtures/SharedCache/Lib/Class1.cs @@ -0,0 +1,6 @@ +namespace SharedCache.Lib +{ + public class Class1 + { + } +} diff --git a/src/Snitch.Tests.Fixtures/SharedCache/Lib/Lib.csproj b/src/Snitch.Tests.Fixtures/SharedCache/Lib/Lib.csproj new file mode 100644 index 0000000..48f4377 --- /dev/null +++ b/src/Snitch.Tests.Fixtures/SharedCache/Lib/Lib.csproj @@ -0,0 +1,12 @@ + + + + netstandard2.0;net8.0 + + + + + + + + diff --git a/src/Snitch.Tests.Fixtures/SharedCache/New/Class1.cs b/src/Snitch.Tests.Fixtures/SharedCache/New/Class1.cs new file mode 100644 index 0000000..c7bc525 --- /dev/null +++ b/src/Snitch.Tests.Fixtures/SharedCache/New/Class1.cs @@ -0,0 +1,6 @@ +namespace SharedCache.New +{ + public class Class1 + { + } +} diff --git a/src/Snitch.Tests.Fixtures/SharedCache/New/New.csproj b/src/Snitch.Tests.Fixtures/SharedCache/New/New.csproj new file mode 100644 index 0000000..0df45f9 --- /dev/null +++ b/src/Snitch.Tests.Fixtures/SharedCache/New/New.csproj @@ -0,0 +1,16 @@ + + + + net8.0 + + + + + + + + + + + + diff --git a/src/Snitch.Tests.Fixtures/SharedCache/Old/Class1.cs b/src/Snitch.Tests.Fixtures/SharedCache/Old/Class1.cs new file mode 100644 index 0000000..bbadbaa --- /dev/null +++ b/src/Snitch.Tests.Fixtures/SharedCache/Old/Class1.cs @@ -0,0 +1,6 @@ +namespace SharedCache.Old +{ + public class Class1 + { + } +} diff --git a/src/Snitch.Tests.Fixtures/SharedCache/Old/Old.csproj b/src/Snitch.Tests.Fixtures/SharedCache/Old/Old.csproj new file mode 100644 index 0000000..094ba03 --- /dev/null +++ b/src/Snitch.Tests.Fixtures/SharedCache/Old/Old.csproj @@ -0,0 +1,16 @@ + + + + netstandard2.0 + + + + + + + + + + + + diff --git a/src/Snitch.Tests.Fixtures/SharedCache/SharedCache.slnx b/src/Snitch.Tests.Fixtures/SharedCache/SharedCache.slnx new file mode 100644 index 0000000..c6d1033 --- /dev/null +++ b/src/Snitch.Tests.Fixtures/SharedCache/SharedCache.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/src/Snitch.Tests/Expectations/Asdf.Default.verified.txt b/src/Snitch.Tests/Expectations/Asdf.Default.verified.txt index 208fb80..69f15f0 100644 --- a/src/Snitch.Tests/Expectations/Asdf.Default.verified.txt +++ b/src/Snitch.Tests/Expectations/Asdf.Default.verified.txt @@ -3,6 +3,7 @@ Analyzing CentralizedPackageManagement.sln Analyzing Asdf... * Analyzing Qwerty (net8.0)... * Analyzing Zxcv (net8.0)... +Analyzing Qwerty... ╭─────────────────────────────────────────────────────────╮ │ Packages that can be removed from Asdf: │ diff --git a/src/Snitch.Tests/Expectations/Asdf.Strict_NoPreRelease.verified.txt b/src/Snitch.Tests/Expectations/Asdf.Strict_NoPreRelease.verified.txt index 7a980d7..9b50511 100644 --- a/src/Snitch.Tests/Expectations/Asdf.Strict_NoPreRelease.verified.txt +++ b/src/Snitch.Tests/Expectations/Asdf.Strict_NoPreRelease.verified.txt @@ -3,6 +3,7 @@ Analyzing CentralizedPackageManagement.sln Analyzing Asdf... * Analyzing Qwerty (net8.0)... * Analyzing Zxcv (net8.0)... +Analyzing Qwerty... ╭─────────────────────────────────────────────────────────╮ │ Packages that can be removed from Asdf: │ diff --git a/src/Snitch.Tests/Expectations/SharedCache.Default.verified.txt b/src/Snitch.Tests/Expectations/SharedCache.Default.verified.txt new file mode 100644 index 0000000..41350f0 --- /dev/null +++ b/src/Snitch.Tests/Expectations/SharedCache.Default.verified.txt @@ -0,0 +1,15 @@ +Analyzing... +Analyzing SharedCache.slnx +Analyzing Old... + * Analyzing Lib (netstandard2.0)... +Analyzing New... + * Analyzing Lib (net8.0)... + +╭─────────────────────────────────────────╮ +│ Packages that can be removed from Old: │ +│ ┌────────────────────┬────────────────┐ │ +│ │ Package │ Referenced by │ │ +│ ├────────────────────┼────────────────┤ │ +│ │ Newtonsoft.Json │ Lib │ │ +│ └────────────────────┴────────────────┘ │ +╰─────────────────────────────────────────╯ \ No newline at end of file diff --git a/src/Snitch.Tests/ProgramTests.cs b/src/Snitch.Tests/ProgramTests.cs index 27f9c4d..f76c642 100644 --- a/src/Snitch.Tests/ProgramTests.cs +++ b/src/Snitch.Tests/ProgramTests.cs @@ -430,6 +430,21 @@ public async Task Should_Not_Interpret_A_Pinned_Package_Version_As_Markup() await Verifier.Verify(output); } + [Fact] + [Expectation("SharedCache", "Default")] + public async Task Should_Not_Reuse_A_Multi_Targeting_Project_Across_Target_Frameworks() + { + // Given + var solution = Fixture.GetPath("SharedCache/SharedCache.slnx"); + + // When + var (exitCode, output) = await Fixture.Run(solution); + + // Then + exitCode.ShouldBe(0); + await Verifier.Verify(output); + } + public sealed class Fixture { public static string GetPath(string path) diff --git a/src/Snitch/Analysis/Models/Project.cs b/src/Snitch/Analysis/Models/Project.cs index b49dc6a..be4e102 100644 --- a/src/Snitch/Analysis/Models/Project.cs +++ b/src/Snitch/Analysis/Models/Project.cs @@ -14,11 +14,19 @@ internal sealed class Project public List ProjectReferences { get; } public List Packages { get; } - public Project(string path) + /// + /// Gets the target framework the project was asked to build for, which is + /// empty when none was given. A multi targeting project yields different + /// packages per framework, so this is part of its identity. + /// + public string RequestedTargetFramework { get; } + + public Project(string path, string? requestedTargetFramework = null) { Path = path ?? throw new ArgumentNullException(nameof(path)); Name = System.IO.Path.GetFileNameWithoutExtension(Path); TargetFramework = string.Empty; + RequestedTargetFramework = requestedTargetFramework ?? string.Empty; ProjectReferences = new List(); Packages = new List(); } diff --git a/src/Snitch/Analysis/Models/ProjectComparer.cs b/src/Snitch/Analysis/Models/ProjectComparer.cs index 46d9aff..55f720d 100644 --- a/src/Snitch/Analysis/Models/ProjectComparer.cs +++ b/src/Snitch/Analysis/Models/ProjectComparer.cs @@ -18,12 +18,20 @@ public bool Equals([AllowNull] Project x, [AllowNull] Project y) return false; } - return x.Path.Equals(y.Path, StringComparison.OrdinalIgnoreCase); + return x.Path.Equals(y.Path, StringComparison.OrdinalIgnoreCase) + && x.RequestedTargetFramework.Equals(y.RequestedTargetFramework, StringComparison.OrdinalIgnoreCase); } public int GetHashCode([DisallowNull] Project obj) { - return obj?.Path?.GetHashCode(StringComparison.OrdinalIgnoreCase) ?? 0; + if (obj?.Path == null) + { + return 0; + } + + return HashCode.Combine( + obj.Path.GetHashCode(StringComparison.OrdinalIgnoreCase), + obj.RequestedTargetFramework.GetHashCode(StringComparison.OrdinalIgnoreCase)); } } } \ No newline at end of file diff --git a/src/Snitch/Analysis/ProjectBuilder.cs b/src/Snitch/Analysis/ProjectBuilder.cs index 45ea536..ae328df 100644 --- a/src/Snitch/Analysis/ProjectBuilder.cs +++ b/src/Snitch/Analysis/ProjectBuilder.cs @@ -27,9 +27,14 @@ public ProjectBuildResult Build( { var manager = new AnalyzerManager(); - // Keyed by the full path — two projects can share a file name. - var built = cache?.ToDictionary(x => x.Path, x => x, StringComparer.OrdinalIgnoreCase) - ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + // Keyed by the full path — two projects can share a file name — plus the + // requested target framework, since a multi targeting project referenced + // from two roots yields different packages for each of them. + var built = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var cached in cache ?? Enumerable.Empty()) + { + Remember(built, cached); + } var project = Build(manager, path, tfm, skip, built); @@ -60,13 +65,13 @@ private Project Build( path = Path.GetFullPath(path); - // Already built this project? - if (built.TryGetValue(path, out var project)) + // Already built this project for this target framework? + if (built.TryGetValue(GetCacheKey(path, tfm), out var project)) { return project; } - project = new Project(path); + project = new Project(path, tfm); var result = Build(manager, project, tfm, indentation); if (result == null) @@ -100,7 +105,7 @@ private Project Build( project.LockFilePath = assetPath; // Add the project to the built list. - built.Add(path, project); + Remember(built, project); // Get the package references. var restoredVersions = GetRestoredVersions(assetPath, project.TargetFramework); @@ -151,6 +156,25 @@ private Project Build( return project; } + private static void Remember(Dictionary built, Project project) + { + // Under the framework that was asked for, and under the one it resolved to. + // Without the latter a project built with no framework in particular would be + // built again the moment a parent asks for the very framework it settled on. + built[GetCacheKey(project)] = project; + built[GetCacheKey(project.Path, project.TargetFramework)] = project; + } + + private static string GetCacheKey(Project project) + { + return GetCacheKey(project.Path, project.RequestedTargetFramework); + } + + private static string GetCacheKey(string path, string? tfm) + { + return $"{path}|{tfm}"; + } + private static Dictionary GetRestoredVersions(string? lockFilePath, string? targetFramework) { var result = new Dictionary(StringComparer.OrdinalIgnoreCase); From 018d49ecad294ea6bbe4570b8ad04fef158f2ab2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Urba=C5=9B?= Date: Fri, 11 Sep 2026 21:01:13 +0200 Subject: [PATCH 25/27] perf: reuse a single target project for any target framework Keying the cache by the requested framework meant a project was built again whenever it was first built for a specific framework and later asked for without one. That only matters for a project that targets several frameworks; one that targets a single framework builds the same way either way, so it is now remembered for any request. --- .../Expectations/Asdf.Default.verified.txt | 1 - .../Asdf.Strict_NoPreRelease.verified.txt | 1 - src/Snitch/Analysis/Models/Project.cs | 7 +++++++ src/Snitch/Analysis/ProjectBuilder.cs | 12 ++++++++++++ 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/Snitch.Tests/Expectations/Asdf.Default.verified.txt b/src/Snitch.Tests/Expectations/Asdf.Default.verified.txt index 69f15f0..208fb80 100644 --- a/src/Snitch.Tests/Expectations/Asdf.Default.verified.txt +++ b/src/Snitch.Tests/Expectations/Asdf.Default.verified.txt @@ -3,7 +3,6 @@ Analyzing CentralizedPackageManagement.sln Analyzing Asdf... * Analyzing Qwerty (net8.0)... * Analyzing Zxcv (net8.0)... -Analyzing Qwerty... ╭─────────────────────────────────────────────────────────╮ │ Packages that can be removed from Asdf: │ diff --git a/src/Snitch.Tests/Expectations/Asdf.Strict_NoPreRelease.verified.txt b/src/Snitch.Tests/Expectations/Asdf.Strict_NoPreRelease.verified.txt index 9b50511..7a980d7 100644 --- a/src/Snitch.Tests/Expectations/Asdf.Strict_NoPreRelease.verified.txt +++ b/src/Snitch.Tests/Expectations/Asdf.Strict_NoPreRelease.verified.txt @@ -3,7 +3,6 @@ Analyzing CentralizedPackageManagement.sln Analyzing Asdf... * Analyzing Qwerty (net8.0)... * Analyzing Zxcv (net8.0)... -Analyzing Qwerty... ╭─────────────────────────────────────────────────────────╮ │ Packages that can be removed from Asdf: │ diff --git a/src/Snitch/Analysis/Models/Project.cs b/src/Snitch/Analysis/Models/Project.cs index be4e102..69f0f8f 100644 --- a/src/Snitch/Analysis/Models/Project.cs +++ b/src/Snitch/Analysis/Models/Project.cs @@ -21,6 +21,13 @@ internal sealed class Project /// public string RequestedTargetFramework { get; } + /// + /// Gets or sets a value indicating whether the project targets more than one + /// framework. A project that targets exactly one builds the same way whichever + /// framework was asked for, so it can be reused for any of them. + /// + public bool IsMultiTargeting { get; set; } + public Project(string path, string? requestedTargetFramework = null) { Path = path ?? throw new ArgumentNullException(nameof(path)); diff --git a/src/Snitch/Analysis/ProjectBuilder.cs b/src/Snitch/Analysis/ProjectBuilder.cs index ae328df..68f3ea8 100644 --- a/src/Snitch/Analysis/ProjectBuilder.cs +++ b/src/Snitch/Analysis/ProjectBuilder.cs @@ -163,6 +163,12 @@ private static void Remember(Dictionary built, Project project) // built again the moment a parent asks for the very framework it settled on. built[GetCacheKey(project)] = project; built[GetCacheKey(project.Path, project.TargetFramework)] = project; + + if (!project.IsMultiTargeting) + { + // Only one framework to build, so it answers for any request. + built[GetCacheKey(project.Path, null)] = project; + } } private static string GetCacheKey(Project project) @@ -228,6 +234,12 @@ private static Dictionary GetRestoredVersions(string? lockFilePa var projectAnalyzer = manager.GetProject(project.Path); var results = (IEnumerable)projectAnalyzer.Build(); + project.IsMultiTargeting = results + .Select(x => x.TargetFramework) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Skip(1).Any(); + if (!string.IsNullOrWhiteSpace(tfm)) { var closest = results.GetNearestFrameworkMoniker(tfm); From e7002e101a289c3269e7cd45b4be1b32d56e5ecc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Urba=C5=9B?= Date: Fri, 11 Sep 2026 21:10:24 +0200 Subject: [PATCH 26/27] test: cover a project whose name is also markup The escaping in the build progress output and in the analyzed file name had no test of its own, only the reporter did. --- .../Markup/Weird[1]/Class1.cs | 6 ++++++ .../Markup/Weird[1]/Weird[1].csproj | 11 +++++++++++ .../Expectations/Markup.ProjectName.verified.txt | 5 +++++ src/Snitch.Tests/ProgramTests.cs | 15 +++++++++++++++ 4 files changed, 37 insertions(+) create mode 100644 src/Snitch.Tests.Fixtures/Markup/Weird[1]/Class1.cs create mode 100644 src/Snitch.Tests.Fixtures/Markup/Weird[1]/Weird[1].csproj create mode 100644 src/Snitch.Tests/Expectations/Markup.ProjectName.verified.txt diff --git a/src/Snitch.Tests.Fixtures/Markup/Weird[1]/Class1.cs b/src/Snitch.Tests.Fixtures/Markup/Weird[1]/Class1.cs new file mode 100644 index 0000000..3e61001 --- /dev/null +++ b/src/Snitch.Tests.Fixtures/Markup/Weird[1]/Class1.cs @@ -0,0 +1,6 @@ +namespace Markup.Weird +{ + public class Class1 + { + } +} diff --git a/src/Snitch.Tests.Fixtures/Markup/Weird[1]/Weird[1].csproj b/src/Snitch.Tests.Fixtures/Markup/Weird[1]/Weird[1].csproj new file mode 100644 index 0000000..7977d1b --- /dev/null +++ b/src/Snitch.Tests.Fixtures/Markup/Weird[1]/Weird[1].csproj @@ -0,0 +1,11 @@ + + + + netstandard2.0 + + + + + + + diff --git a/src/Snitch.Tests/Expectations/Markup.ProjectName.verified.txt b/src/Snitch.Tests/Expectations/Markup.ProjectName.verified.txt new file mode 100644 index 0000000..18b94c0 --- /dev/null +++ b/src/Snitch.Tests/Expectations/Markup.ProjectName.verified.txt @@ -0,0 +1,5 @@ +Analyzing... +Analyzing Weird[1].csproj +Analyzing Weird[1] (netstandard2.0)... + +Everything looks good! \ No newline at end of file diff --git a/src/Snitch.Tests/ProgramTests.cs b/src/Snitch.Tests/ProgramTests.cs index f76c642..1d3cc7d 100644 --- a/src/Snitch.Tests/ProgramTests.cs +++ b/src/Snitch.Tests/ProgramTests.cs @@ -445,6 +445,21 @@ public async Task Should_Not_Reuse_A_Multi_Targeting_Project_Across_Target_Frame await Verifier.Verify(output); } + [Fact] + [Expectation("Markup", "ProjectName")] + public async Task Should_Not_Interpret_A_Project_Name_As_Markup_While_Building() + { + // Given + var project = Fixture.GetPath("Markup/Weird[1]/Weird[1].csproj"); + + // When + var (exitCode, output) = await Fixture.Run(project, "--tfm", "netstandard2.0"); + + // Then + exitCode.ShouldBe(0); + await Verifier.Verify(output); + } + public sealed class Fixture { public static string GetPath(string path) From e26e7595dfd3916467c0a58132be21b35ae58e7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Urba=C5=9B?= Date: Fri, 11 Sep 2026 21:32:49 +0200 Subject: [PATCH 27/27] fix: say which framework a project actually targets Asking for a framework nothing was compatible with indexed the mapping with the null the reducer had returned, so the run ended with "Value cannot be null. (Parameter 'key')" and no hint of what went wrong. It now names the framework that was asked for and the ones the project has. Also names the skipped project rather than the one referencing it in the "Skipping Non .NET Project" line, and covers the remaining untested paths: private assets that keep compile time assets, a reference that is not a .NET project, the old csproj format, and a target framework that is also markup. --- src/Snitch.Tests.Fixtures/Legacy/Class1.cs | 6 ++ .../Legacy/Legacy.csproj | 19 +++++ .../Legacy/packages.config | 4 + .../NonDotNet/App/App.csproj | 15 ++++ .../NonDotNet/App/Class1.cs | 6 ++ .../NonDotNet/Native/Native.vcxproj | 8 ++ .../Baz.Incompatible_Tfm.verified.txt | 5 ++ .../Expectations/Baz.Markup_Tfm.verified.txt | 5 ++ .../Expectations/Legacy.Default.verified.txt | 6 ++ .../NonDotNet.Default.verified.txt | 7 ++ .../NonDotNet.netstandard2.0.verified.txt | 7 ++ src/Snitch.Tests/PackageTests.cs | 34 ++++++++ src/Snitch.Tests/ProgramTests.cs | 77 +++++++++++++++++++ src/Snitch/Analysis/ProjectBuilder.cs | 7 +- .../Extensions/AnalyzerResultExtensions.cs | 15 +++- 15 files changed, 218 insertions(+), 3 deletions(-) create mode 100644 src/Snitch.Tests.Fixtures/Legacy/Class1.cs create mode 100644 src/Snitch.Tests.Fixtures/Legacy/Legacy.csproj create mode 100644 src/Snitch.Tests.Fixtures/Legacy/packages.config create mode 100644 src/Snitch.Tests.Fixtures/NonDotNet/App/App.csproj create mode 100644 src/Snitch.Tests.Fixtures/NonDotNet/App/Class1.cs create mode 100644 src/Snitch.Tests.Fixtures/NonDotNet/Native/Native.vcxproj create mode 100644 src/Snitch.Tests/Expectations/Baz.Incompatible_Tfm.verified.txt create mode 100644 src/Snitch.Tests/Expectations/Baz.Markup_Tfm.verified.txt create mode 100644 src/Snitch.Tests/Expectations/Legacy.Default.verified.txt create mode 100644 src/Snitch.Tests/Expectations/NonDotNet.Default.verified.txt create mode 100644 src/Snitch.Tests/Expectations/NonDotNet.netstandard2.0.verified.txt diff --git a/src/Snitch.Tests.Fixtures/Legacy/Class1.cs b/src/Snitch.Tests.Fixtures/Legacy/Class1.cs new file mode 100644 index 0000000..ecb2906 --- /dev/null +++ b/src/Snitch.Tests.Fixtures/Legacy/Class1.cs @@ -0,0 +1,6 @@ +namespace Legacy +{ + public class Class1 + { + } +} diff --git a/src/Snitch.Tests.Fixtures/Legacy/Legacy.csproj b/src/Snitch.Tests.Fixtures/Legacy/Legacy.csproj new file mode 100644 index 0000000..a9c57e2 --- /dev/null +++ b/src/Snitch.Tests.Fixtures/Legacy/Legacy.csproj @@ -0,0 +1,19 @@ + + + + Debug + AnyCPU + Library + Legacy + Legacy + v4.7.2 + + + + + + + + + + diff --git a/src/Snitch.Tests.Fixtures/Legacy/packages.config b/src/Snitch.Tests.Fixtures/Legacy/packages.config new file mode 100644 index 0000000..161dc90 --- /dev/null +++ b/src/Snitch.Tests.Fixtures/Legacy/packages.config @@ -0,0 +1,4 @@ + + + + diff --git a/src/Snitch.Tests.Fixtures/NonDotNet/App/App.csproj b/src/Snitch.Tests.Fixtures/NonDotNet/App/App.csproj new file mode 100644 index 0000000..edaf16a --- /dev/null +++ b/src/Snitch.Tests.Fixtures/NonDotNet/App/App.csproj @@ -0,0 +1,15 @@ + + + + netstandard2.0 + + + + + + + + + + + diff --git a/src/Snitch.Tests.Fixtures/NonDotNet/App/Class1.cs b/src/Snitch.Tests.Fixtures/NonDotNet/App/Class1.cs new file mode 100644 index 0000000..fa0c070 --- /dev/null +++ b/src/Snitch.Tests.Fixtures/NonDotNet/App/Class1.cs @@ -0,0 +1,6 @@ +namespace NonDotNet.App +{ + public class Class1 + { + } +} diff --git a/src/Snitch.Tests.Fixtures/NonDotNet/Native/Native.vcxproj b/src/Snitch.Tests.Fixtures/NonDotNet/Native/Native.vcxproj new file mode 100644 index 0000000..44f79df --- /dev/null +++ b/src/Snitch.Tests.Fixtures/NonDotNet/Native/Native.vcxproj @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/Snitch.Tests/Expectations/Baz.Incompatible_Tfm.verified.txt b/src/Snitch.Tests/Expectations/Baz.Incompatible_Tfm.verified.txt new file mode 100644 index 0000000..1eb3ef6 --- /dev/null +++ b/src/Snitch.Tests/Expectations/Baz.Incompatible_Tfm.verified.txt @@ -0,0 +1,5 @@ +Analyzing... +Analyzing Baz.csproj +Analyzing Baz (net45)... +Error: The project does not target anything compatible with 'net45'. It targets: +netstandard2.0. \ No newline at end of file diff --git a/src/Snitch.Tests/Expectations/Baz.Markup_Tfm.verified.txt b/src/Snitch.Tests/Expectations/Baz.Markup_Tfm.verified.txt new file mode 100644 index 0000000..fa0c694 --- /dev/null +++ b/src/Snitch.Tests/Expectations/Baz.Markup_Tfm.verified.txt @@ -0,0 +1,5 @@ +Analyzing... +Analyzing Baz.csproj +Analyzing Baz ([x])... +Error: The project does not target anything compatible with 'unsupported'. It +targets: netstandard2.0. \ No newline at end of file diff --git a/src/Snitch.Tests/Expectations/Legacy.Default.verified.txt b/src/Snitch.Tests/Expectations/Legacy.Default.verified.txt new file mode 100644 index 0000000..843075a --- /dev/null +++ b/src/Snitch.Tests/Expectations/Legacy.Default.verified.txt @@ -0,0 +1,6 @@ +Analyzing... +Analyzing Legacy.csproj +Analyzing Legacy... +WARN: Old CSPROJ format can't be analyzed + +Everything looks good! \ No newline at end of file diff --git a/src/Snitch.Tests/Expectations/NonDotNet.Default.verified.txt b/src/Snitch.Tests/Expectations/NonDotNet.Default.verified.txt new file mode 100644 index 0000000..22ba95d --- /dev/null +++ b/src/Snitch.Tests/Expectations/NonDotNet.Default.verified.txt @@ -0,0 +1,7 @@ +Analyzing... +Analyzing App.csproj +Analyzing App... +Skipping Non .NET Project Native + + +Everything looks good! \ No newline at end of file diff --git a/src/Snitch.Tests/Expectations/NonDotNet.netstandard2.0.verified.txt b/src/Snitch.Tests/Expectations/NonDotNet.netstandard2.0.verified.txt new file mode 100644 index 0000000..548f4fb --- /dev/null +++ b/src/Snitch.Tests/Expectations/NonDotNet.netstandard2.0.verified.txt @@ -0,0 +1,7 @@ +Analyzing... +Analyzing App.csproj +Analyzing App (netstandard2.0)... +Skipping Non .NET Project Native (netstandard2.0) + + +Everything looks good! \ No newline at end of file diff --git a/src/Snitch.Tests/PackageTests.cs b/src/Snitch.Tests/PackageTests.cs index 64500c4..3bd7a4a 100644 --- a/src/Snitch.Tests/PackageTests.cs +++ b/src/Snitch.Tests/PackageTests.cs @@ -93,6 +93,40 @@ public void Should_Not_Consider_Different_Version_Ranges_To_Be_The_Same_Version( left.IsSameVersion(right).ShouldBeFalse(); } + [Theory] + [InlineData("all")] + [InlineData("All")] + [InlineData("compile")] + [InlineData("Compile")] + [InlineData("compile;runtime")] + [InlineData("runtime;compile")] + [InlineData("runtime, compile")] + [InlineData(" compile ")] + public void Should_Consider_A_Package_Private_When_It_Keeps_Its_Compile_Time_Assets(string privateAssets) + { + // Given, When + var package = new Package("Autofac", "4.9.4", privateAssets); + + // Then + package.IsPrivate.ShouldBeTrue(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("runtime")] + [InlineData("runtime;native")] + [InlineData("none")] + [InlineData("compiler")] + public void Should_Not_Consider_A_Package_Private_When_Its_Compile_Time_Assets_Flow(string privateAssets) + { + // Given, When + var package = new Package("Autofac", "4.9.4", privateAssets); + + // Then + package.IsPrivate.ShouldBeFalse(); + } + [Fact] public void Should_Throw_For_A_Package_With_An_Invalid_Version() { diff --git a/src/Snitch.Tests/ProgramTests.cs b/src/Snitch.Tests/ProgramTests.cs index 1d3cc7d..e365c1f 100644 --- a/src/Snitch.Tests/ProgramTests.cs +++ b/src/Snitch.Tests/ProgramTests.cs @@ -460,6 +460,83 @@ public async Task Should_Not_Interpret_A_Project_Name_As_Markup_While_Building() await Verifier.Verify(output); } + [Fact] + [Expectation("Baz", "Incompatible_Tfm")] + public async Task Should_Explain_Itself_When_No_Target_Framework_Matches() + { + // Given + var project = Fixture.GetPath("Baz/Baz.csproj"); + + // When + var (_, output) = await Fixture.Run(project, "--tfm", "net45"); + + // Then + output.ShouldNotContain("Parameter 'key'"); + await Verifier.Verify(output); + } + + [Fact] + [Expectation("Baz", "Markup_Tfm")] + public async Task Should_Not_Interpret_A_Target_Framework_As_Markup() + { + // Given + var project = Fixture.GetPath("Baz/Baz.csproj"); + + // When + var (_, output) = await Fixture.Run(project, "--tfm", "[x]"); + + // Then + output.ShouldContain("[x]"); + await Verifier.Verify(output); + } + + [Fact] + [Expectation("NonDotNet", "Default")] + public async Task Should_Skip_A_Referenced_Project_That_Is_Not_Dot_Net() + { + // Given + var project = Fixture.GetPath("NonDotNet/App/App.csproj"); + + // When + var (exitCode, output) = await Fixture.Run(project); + + // Then + exitCode.ShouldBe(0); + output.ShouldContain("Skipping Non .NET Project Native"); + await Verifier.Verify(output); + } + + [Fact] + [Expectation("NonDotNet", "netstandard2.0")] + public async Task Should_Skip_A_Referenced_Project_That_Is_Not_Dot_Net_When_Specifying_Framework() + { + // Given + var project = Fixture.GetPath("NonDotNet/App/App.csproj"); + + // When + var (exitCode, output) = await Fixture.Run(project, "--tfm", "netstandard2.0"); + + // Then + exitCode.ShouldBe(0); + await Verifier.Verify(output); + } + + [Fact] + [Expectation("Legacy", "Default")] + public async Task Should_Warn_About_A_Project_In_The_Old_Csproj_Format() + { + // Given + var project = Fixture.GetPath("Legacy/Legacy.csproj"); + + // When + var (exitCode, output) = await Fixture.Run(project); + + // Then + exitCode.ShouldBe(0); + output.ShouldContain("Old CSPROJ format can't be analyzed"); + await Verifier.Verify(output); + } + public sealed class Fixture { public static string GetPath(string path) diff --git a/src/Snitch/Analysis/ProjectBuilder.cs b/src/Snitch/Analysis/ProjectBuilder.cs index 68f3ea8..f9038a2 100644 --- a/src/Snitch/Analysis/ProjectBuilder.cs +++ b/src/Snitch/Analysis/ProjectBuilder.cs @@ -140,9 +140,12 @@ private Project Build( if (!projectReferencePath.EndsWith("csproj", StringComparison.OrdinalIgnoreCase) && !projectReferencePath.EndsWith("fsproj", StringComparison.OrdinalIgnoreCase)) { + // The reference is the one being skipped, not the project holding it. + var skipped = Path.GetFileNameWithoutExtension(projectReferencePath).EscapeMarkup(); + _console.MarkupLine(string.IsNullOrWhiteSpace(tfm) - ? $"Skipping Non .NET Project [aqua]{project.Name.EscapeMarkup()}[/]" - : $"Skipping Non .NET Project [aqua]{project.Name.EscapeMarkup()}[/] [grey] ({tfm.EscapeMarkup()})[/]"); + ? $"Skipping Non .NET Project [aqua]{skipped}[/]" + : $"Skipping Non .NET Project [aqua]{skipped}[/] [grey] ({tfm.EscapeMarkup()})[/]"); _console.WriteLine(); diff --git a/src/Snitch/Utilities/Extensions/AnalyzerResultExtensions.cs b/src/Snitch/Utilities/Extensions/AnalyzerResultExtensions.cs index e9acd27..959d5d1 100644 --- a/src/Snitch/Utilities/Extensions/AnalyzerResultExtensions.cs +++ b/src/Snitch/Utilities/Extensions/AnalyzerResultExtensions.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using Buildalyzer; @@ -31,7 +32,19 @@ private static string GetNearestFrameworkMoniker(this NuGetFramework framework, candidates.ToDictionary( x => NuGetFramework.Parse(x, provider), y => y, new NuGetFrameworkFullComparer())); - return mappings[reducer.GetNearest(framework, mappings.Keys)]; + var nearest = reducer.GetNearest(framework, mappings.Keys); + if (nearest == null) + { + // Nothing the project targets is compatible with what was asked for. + var available = mappings.Count == 0 + ? "none" + : string.Join(", ", mappings.Values); + + throw new InvalidOperationException( + $"The project does not target anything compatible with '{framework.GetShortFolderName()}'. It targets: {available}."); + } + + return mappings[nearest]; } } } \ No newline at end of file