Skip to content

Instantly share code, notes, and snippets.

@Eibwen
Last active March 9, 2021 19:42
Show Gist options
  • Save Eibwen/9d24f8510ec42ac053b7562167f7a2aa to your computer and use it in GitHub Desktop.
Save Eibwen/9d24f8510ec42ac053b7562167f7a2aa to your computer and use it in GitHub Desktop.
Keep Nuget references honest, dotnet core

Keep Nuget References and similar honest between auto-merging and various nuget tools

If you ever have build/deploy issues because git merges are stupid sometimes and include the old version of a nuget along with the new, or someone doesn't use proper tooling to update or add nuget references to your projects, this will help discover those in a quick and helpful manor!

Tests include:

  • Repository_should_NOT_contain_any_csproj_files_which_are_not_referenced_by_the_sln

    • Makes sure you don't have any stray csproj files in your folders that are not referenced by the main sln file
  • All_target_frameworks_should_match

    • Make sure that all your projects are up-to-date with each other
    • This works by checking TargetFramework's full string, then comparing that count to the count without the framework version (meaning you can have netcoreapp2.2 along with netstandard2.0, but NOT netcoreapp2.2 along with netcoreapp2.1)
    • NOT (currently) support multiple target versions
    • Also this test has one of the least helpful error messages: Expected grouped.Count() to be 2 because similar framework types seem to have multiple versions, but found 3.
  • Projects_should_all_be_in_folders_named_for_themselves

    • This enforces that all projects should have a parent folder which matches the name of the project
    • Note: this works by a simple <project name>.StartsWith(<folder name>), so is not very strict
  • Should_NOT_contain_mismatched_referenced_versions_nugets

    • Make sure that all of your projects that reference Newtonsoft.Json or any other nuget packages, that all are on the exact same version
    • Happens commonly if someone uses the Nuget Package Manager for project, instead of for Solution (or if you have multiple solutions in a repository, but that is less supported by the other tests here)
  • Should_NOT_contain_duplicate_nuget_references

    • Make sure that no project references the same nuget package twice
    • Happens commonly from automatic merge conflict resolutions in git
  • Should_NOT_reference_nugets_by_filepaths

    • Make sure that nobody accidently references a dll which is part of a nuget from the local copy of the dll
    • When this happens it generally will cause build errors on a CI machine
    • Happens commonly if one is using ReSharper and hits alt-enter to add a nuget reference in dotnet core (this was fixed a few versions back in .NetFramework, so I'm sure it will happen less commonly soon)

Not supported

  • Mono-repo ideas (you could modify/disable the tests which fail in that scenario fairly easily)
/*
* Copyright (C) 2019 Greg Walker <https://github.com/eibwen>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* Shared under GNU GPLv3, see <https://www.gnu.org/licenses/>.
*/
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using NUnit.Framework;
namespace Common.Solution.UnitTests
{
/// <summary>
/// Helpers for finding and parsing files that are part of the solution
/// </summary>
public class FileTestHelpers
{
/// <summary>
/// Find the base directory for this solution
/// </summary>
/// <remarks>Based on the main .sln file being located in that directory</remarks>
public static string GetBaseDirectory()
{
return Path.GetDirectoryName(GetSolutionFile());
}
/// <summary>
/// Parse the (dotnet core) .sln file to get all references that exist in it
/// </summary>
public static IEnumerable<Project> GetAllProjectFiles(string solutionFile = null)
{
var solution = solutionFile ?? GetSolutionFile();
var allLines = File.ReadLines(solution).ToList();
var references = allLines.Select(x => x.Trim()).Where(x => x.StartsWith("{"));
var projects = allLines.Where(x => x.StartsWith("Project(")).Select(x => x.Replace('\\', Path.DirectorySeparatorChar));
//var parentChildren = references.Where(x => x.Contains("} = {"))
// .Select(x => new { Proj = new Guid(x.Substring(1, 36)), Folder = new Guid(x.Substring(42, 36)) })
// .ToList();
//return projObjects.Where(x => parentChildren.Any(m => m.Proj == x.TypeId));
//return projObjects.Where(x => !parentChildren.Any(m => m.Folder == x.TypeId));
var buildSettings = references.Where(x => !x.Contains("} = {"))
.Select(x => new Guid(x.Substring(1, 36)))
.Distinct()
.ToList();
var projObjects = projects.Select(x => new Project(x));
return projObjects.Where(x => buildSettings.Any(m => m == x.TypeId));
}
/// <summary>
/// A class to parse and store the results of the reference lines of a dotnet core sln file
/// </summary>
public class Project
{
public Project(string raw)
{
var pattern = @"^Project\(""{(?<id>[0-9A-F-]+)}""\) = ""(?<name>[^""]+)"", ""(?<proj>[^""]+)"", ""{(?<type>[0-9A-F-]+)}""$";
var m = Regex.Match(raw, pattern);
Id = Guid.Parse(m.Groups["id"].Value);
Name = m.Groups["name"].Value;
File = m.Groups["proj"].Value;
TypeId = Guid.Parse(m.Groups["type"].Value);
}
public Guid Id { get; set; }
public string Name { get; set; }
public string File { get; set; }
public Guid TypeId { get; set; }
}
/// <summary>
/// Walk up the directory tree from the <see cref="TestContext.WorkDirectory"/> until we find a sln file
/// </summary>
public static string GetSolutionFile(string expectedSolution = "*", bool allowMultipleMatches = true)
{
var startDirectory = TestContext.CurrentContext.WorkDirectory;
while (true)
{
var foundSolutions = Directory.EnumerateFiles(startDirectory, expectedSolution + ".sln", SearchOption.TopDirectoryOnly);
if (foundSolutions.Any())
{
return allowMultipleMatches
? foundSolutions.First()
: foundSolutions.Single();
}
else
{
startDirectory = Path.GetDirectoryName(startDirectory);
if (startDirectory == null)
{
throw new Exception("Found no matching solution files");
}
}
}
}
/// <summary>
/// Use this to help debug if <see cref="TestContext.WorkDirectory"/> doesn't work for you
/// </summary>
public static void PrintDirectoryOptions()
{
var list = new List<Func<string>>
{
() => AppDomain.CurrentDomain.BaseDirectory,
() => AppDomain.CurrentDomain.DynamicDirectory,
() => AppDomain.CurrentDomain.RelativeSearchPath,
() => TestContext.CurrentContext.TestDirectory,
() => TestContext.CurrentContext.WorkDirectory,
() => Directory.GetCurrentDirectory(),
};
foreach (var directoryFunc in list)
{
Console.WriteLine(directoryFunc());
}
}
}
}
/*
* Copyright (C) 2019 Greg Walker <https://github.com/eibwen>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* Shared under GNU GPLv3, see <https://www.gnu.org/licenses/>.
*/
using System.Linq;
using System.Text;
using NUnit.Framework;
namespace Common.Solution.UnitTests
{
[TestFixture]
public class NugetReferenceTests
{
[Test]
public void Should_NOT_contain_mismatched_referenced_versions()
{
//Arrange
var references = ProjectFileHelpers.GetAllProjectReferences(ReferenceType.NugetPackage);
//Act
var nugetReferences = references.SelectMany(x => x.Value).GroupBy(x => x.Name);
//Assert
Assert.Multiple(() =>
{
foreach (var nuget in nugetReferences)
{
var versions = nuget.GroupBy(x => x.Version).OrderByDescending(x => x.Count()).ToList();
if (versions.Count > 1)
{
// We have multiple mismatched versions!
var sb = new StringBuilder();
sb.AppendLine("SUGGESTION: Fix this failure last, other issues might show up here falsely");
sb.AppendLine($"Package {nuget.Key} has multiple versions referenced:");
var winner = true;
foreach (var reference in versions)
{
if (winner)
{
sb.AppendLine($" The cool kids are using version {reference.Key}");
}
else
{
sb.AppendLine($" Others are using {reference.Key}");
sb.AppendLine($" > {string.Join(", ", reference.Select(x => x.Project))}");
}
winner = false;
}
sb.AppendLine("> TO FIX: Use the 'Consolidate' tab of Nuget Package Manager");
Assert.Fail(sb.ToString());
}
}
});
}
[Test]
public void Should_NOT_contain_duplicate()
{
//Arrange
var references = ProjectFileHelpers.GetAllProjectReferences();
//Act
var duplicateNames = references.SelectMany(x => x.Value.GroupBy(r => r.Name).Where(g => g.Count() > 1))
.Where(x => x.Any());
//Assert
Assert.Multiple(() =>
{
foreach (var dupeRef in duplicateNames)
{
var sb = new StringBuilder($"{dupeRef.Key} is referenced multiple times INSIDE {dupeRef.First().Project}");
foreach (var reference in dupeRef)
{
sb.AppendLine($" {reference.Name} Version={reference.Version}");
}
sb.AppendLine("> TO FIX: This probably requires manual editing of the project file (merge resolution mistake)");
Assert.Fail(sb.ToString());
}
});
}
[Test]
public void Should_NOT_reference_nugets_by_filepaths()
{
//Arrange
var references = ProjectFileHelpers.GetAllProjectReferences(ReferenceType.DllReference);
//Act
var dllReferences = references.Where(x => x.Value.Count > 0);
var nugetsReferencedByDll = dllReferences.Where(x => x.Value.Any(r => r.HintPath != null && r.HintPath.Contains("nuget")));
//Assert
Assert.Multiple(() =>
{
foreach (var kvp in dllReferences)
{
if (kvp.Value.Any(r => r.HintPath != null && r.HintPath.Contains("nuget")))
{
var sb = new StringBuilder($"Project {kvp.Key} has dlls from a nuget referenced, it SHOULD reference it by a nuget package");
sb.AppendLine("> TO FIX: try just removing the following first, see if you'll get the reference indirectly. If that fails to build, add the reference using a Nuget Package Manager");
foreach (var reference in kvp.Value)
{
sb.AppendLine($" Package: {reference.Name}");
}
Assert.Fail(sb.ToString());
}
}
});
}
}
}
/*
* Copyright (C) 2019 Greg Walker <https://github.com/eibwen>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* Shared under GNU GPLv3, see <https://www.gnu.org/licenses/>.
*/
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Xml.Linq;
namespace Common.Solution.UnitTests
{
/// <summary>
/// Helper methods for finding and parsing .csproj files
/// </summary>
public class ProjectFileHelpers
{
/// <summary>
/// Find all references that a project file contains
/// </summary>
/// <remarks>This always tries to parse ALL references regardless of the <see cref="filterTypes"/>, and filters afterward</remarks>
internal static Dictionary<string, List<Reference>> GetAllProjectReferences(ReferenceType filterTypes = ReferenceType.All)
{
var baseDirectory = FileTestHelpers.GetBaseDirectory();
var projects = FileTestHelpers.GetAllProjectFiles();
var references = projects.Select(x => new
{
Project = x,
References = Reference.ParseProjFile(baseDirectory, x.File).Where(f => filterTypes.HasFlag(f.Type))
});
return references.ToDictionary(x => x.Project.File, x => x.References.ToList());
}
//TODO should maybe make a XElement version of this?:
/// <summary>
/// Allows you to find arbitrary lines from the project file, somewhat limited usage
/// </summary>
/// <remarks>The lines are trimmed before being passed the the <see cref="matcher"/>, so do not include whitespace</remarks>
internal static Dictionary<string, List<string>> GetMatchingProjectFileLines(Func<string, bool> matcher)
{
var baseDirectory = FileTestHelpers.GetBaseDirectory();
var projects = FileTestHelpers.GetAllProjectFiles();
var references = projects.Select(x => new
{
Project = x,
Lines = File.ReadLines(Path.Combine(baseDirectory, x.File)).Select(l => l.Trim()).Where(matcher)
});
return references.ToDictionary(x => x.Project.File, x => x.Lines.ToList());
}
}
/// <summary>
/// A class to parse and store all types of references a project might contain
/// </summary>
internal class Reference
{
public static IEnumerable<Reference> ParseProjFile(string baseDirectory, string projectFile)
{
var xele = XElement.Load(Path.Combine(baseDirectory, projectFile));
xele = StripNamespaces(xele);
var itemGroupChildren = xele.Elements("ItemGroup").SelectMany(x => x.Elements());
return itemGroupChildren.Select(x => ParseElement(projectFile, x))
.Where(x => x != null);
}
public static IEnumerable<Reference> ParseProjContents(string projectFilepath, string fileContent)
{
var projectFile = Path.GetFileName(projectFilepath);
var xele = XElement.Parse(fileContent);
xele = StripNamespaces(xele);
var itemGroupChildren = xele.Elements("ItemGroup").SelectMany(x => x.Elements());
return itemGroupChildren.Select(x => ParseElement(projectFile, x))
.Where(x => x != null);
}
// Source: https://stackoverflow.com/a/1147012/356218
private static XElement StripNamespaces(XElement rootElement)
{
foreach (var element in rootElement.DescendantsAndSelf())
{
// update element name if a namespace is available
if (element.Name.Namespace != XNamespace.None)
{
element.Name = XNamespace.None.GetName(element.Name.LocalName);
}
// check if the element contains attributes with defined namespaces (ignore xml and empty namespaces)
bool hasDefinedNamespaces = element.Attributes().Any(attribute => attribute.IsNamespaceDeclaration ||
(attribute.Name.Namespace != XNamespace.None && attribute.Name.Namespace != XNamespace.Xml));
if (hasDefinedNamespaces)
{
// ignore attributes with a namespace declaration
// strip namespace from attributes with defined namespaces, ignore xml / empty namespaces
// xml namespace is ignored to retain the space preserve attribute
var attributes = element.Attributes()
.Where(attribute => !attribute.IsNamespaceDeclaration)
.Select(attribute =>
(attribute.Name.Namespace != XNamespace.None && attribute.Name.Namespace != XNamespace.Xml) ?
new XAttribute(XNamespace.None.GetName(attribute.Name.LocalName), attribute.Value) :
attribute
);
// replace with attributes result
element.ReplaceAttributes(attributes);
}
}
return rootElement;
}
private static Reference ParseElement(string project, XElement reference)
{
switch (reference.Name.LocalName)
{
case "PackageReference":
if (reference.Attribute("Update") != null)
{
reference.Dump("find me documentation for this please, wtf is it");
return null;
}
return new Reference(project,
ReferenceType.NugetPackage,
reference.Attribute("Include").Value,
reference.Attribute("Version")?.Value);
case "ProjectReference":
return new Reference(project,
ReferenceType.Project,
reference.Attribute("Include").Value,
null);
case "Reference":
if (reference.Attributes().Count() > 1)
{
reference.Dump();
reference.Attributes().Dump();
throw new ArgumentException($"<Reference> has more attributes than expected in {project}");
}
var allowedElements = new HashSet<string>
{
"HintPath",
"SpecificVersion",
"Private",
"RequiredTargetFramework",
"EmbedInteropTypes", // super special case
};
if (reference.Elements().Count(x => !allowedElements.Contains(x.Name.LocalName)) > 0)
{
reference.Dump();
reference.Elements().Dump();
throw new ArgumentException($"<Reference> has more children than expected in {project}");
}
return new Reference(project,
ReferenceType.DllReference,
reference.Attribute("Include").Value,
null,
reference.Element("HintPath")?.Value);
case "AdditionalFiles":
return new Reference(project,
ReferenceType.AdditionalFile,
reference.Attribute("Include").Value,
null);
case "Resource":
return new Reference(project,
ReferenceType.Resource,
reference.Attribute("Include").Value,
null);
case "Analyzer":
return new Reference(project,
ReferenceType.Analyzer,
reference.Attribute("Include").Value,
null);
case "EntityDeploy":
return new Reference(project,
ReferenceType.EntityDataModel,
reference.Attribute("Include").Value,
null);
case "Content":
case "ContentWithTargetPath":
case "Folder":
case "Compile":
case "EmbeddedResource":
case "None":
case "Service": // Just ignoring Service cause I'm not using it for anything
case "AppSettingsFiles": // Again not useful to me
case "AssemblyAttribute":
case "WCFMetadata": // Even more gross
case "WCFMetadataStorage":
case "RuntimeHostConfigurationOption": // Not useful
case "DotNetCliToolReference":
case "BootstrapperPackage":
return null;
case "Page": // UI shit?
case "ApplicationDefinition":
case "AppDesigner":
return null;
// Extension properties
case "SpecFlowFeatureFiles": // Gross
case "NukeMetadata":
case "Protobuf":
return null;
case "SpecFlowObsoleteCodeBehindFiles":
$"## This should be cleaned up?".Dump();
return null;
default:
throw new NotSupportedException($"Do not know how to parse element {reference.Name}, which was found in {project}");
}
}
public Reference(string project, ReferenceType type, string name, string version, string hintPath = null)
{
Project = project;
Name = name;
Version = version;
HintPath = hintPath;
Type = type;
}
public string Project { get; }
public ReferenceType Type { get; }
public string Name { get; }
public string Version { get; }
public string HintPath { get; }
}
[Flags]
internal enum ReferenceType
{
Default = 0,
NugetPackage = (1 << 0),
Project = (1 << 1),
DllReference = (1 << 2),
AdditionalFile = (1 << 3),
Resource = (1 << 4),
Analyzer = (1 << 5),
EntityDataModel = (1 << 6),
All = Default
| NugetPackage
| Project
| DllReference
| AdditionalFile
| Resource
| Analyzer
| EntityDataModel
}
}
/*
* Copyright (C) 2019 Greg Walker <https://github.com/eibwen>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* Shared under GNU GPLv3, see <https://www.gnu.org/licenses/>.
*/
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using FluentAssertions;
using NUnit.Framework;
namespace Common.Solution.UnitTests
{
[TestFixture]
public class SolutionTests
{
[Test]
public void Repository_should_NOT_contain_any_csproj_files_which_are_not_referenced_by_the_sln()
{
//Arrange
var searchPoint = FileTestHelpers.GetBaseDirectory();
var referencedProjectFiles = FileTestHelpers.GetAllProjectFiles();
string CleanupFilepath(string s) => s.Trim('\\', '/').Replace('\\', '/');
//Act
var foundProjFiles = Directory.EnumerateFiles(searchPoint, "*.csproj", SearchOption.AllDirectories)
.Select(x => CleanupFilepath(x.Substring(searchPoint.Length)));
var referencedProjects = referencedProjectFiles.Select(x => CleanupFilepath(x.File));
//Assert
foundProjFiles.Except(referencedProjects).Should().BeEmpty("project files exist in this repository that are not referenced by the solution file");
}
[Test]
public void All_target_frameworks_should_match()
{
//Arrange
var projectTargetFrameworks = ProjectFileHelpers.GetMatchingProjectFileLines(x => x.StartsWith("<TargetFramework>"));
//Act
var frameworks = projectTargetFrameworks.SelectMany(x => x.Value.Select(v => new {Project = x.Key, Framework = v}));
var grouped = frameworks.GroupBy(x => x.Framework);
var groupedFrameworkTypes = frameworks.Select(x => new
{
x.Project,
Framework = Regex.Replace(x.Framework, @"</?TargetFramework>", "")
})
.GroupBy(x => Regex.Replace(x.Framework, @"[0-9\.]$", ""));
//Assert
grouped.Count().Should().Be(groupedFrameworkTypes.Count(), "similar framework types seem to have multiple versions, TODO make this test more helpful on how to fix");
}
[Test]
public void Projects_should_all_be_in_folders_named_for_themselves()
{
//Arrange
var referencedProjectFiles = FileTestHelpers.GetAllProjectFiles();
var excludedFromThisCheck = new[]
{
"docker-compose.dcproj", // Fine with this being in root
};
//Act
var projectDoesNotMatchFolder = referencedProjectFiles
.Where(x => !excludedFromThisCheck.Contains(x.File))
.Select(x => x.File.Split("\\"))
.Where(x => !x[1].StartsWith(x[0]));
//Assert
projectDoesNotMatchFolder.Should().BeEmpty("the folder should be named the same as the project file");
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment