Roslyn: Package consolidation analyzer

Package consolidation is a very important factor in healthy code bases. When you have a single solution, Visual Studio’s package manager is the only tool you ever need, to make sure you’re consolidated across the solution. However, sometimes, teams decide to have multiple solutions and consolidating packages across multiple solutions can be a difficult task.

The more you move forward without consolidating, the harder it will be to consolidate and the more risk you take when building, packaging and deploying. If things were working before and your build order changes and now you get the newest version being packaged instead of the oldest, there are no redirects that can save you, you will deploy something that won’t run!

Roslyn Analyzers to the rescue

The idea is simple, tap into the assembly compilation hook of Roslyn, and for each reference if it contains the word “packages” in them, inspect the packages folder and check for multiple references of the same assembly.

The first thing to change is the actual Analyzer template, because Microsoft templates all analyzers as PNL’s so that they can run on any kind of project. But for this specific case, the projects that this contract deals with are all deployed to Windows Server topologies, either Azure IaaS or Azure PaaS. So I re-created the analyzer project from a PNL to a classic C# class library, so that I can tap into System.IO.

Another thing to note is that we’re not scanning the entire folder, because the rationale is the analyzer should only analyze your current scope, so if you have multiple versions of a package that your solution doesn’t reference, you shouldn’t error in that solution (your current scope), so the analyzer should only look at references for assemblies being compiled at the time and never a full folder scan.

The analyzer

Here’s the analyzer and below a reference to a model object called “Package” that I ended up creating because the analyzer was getting too big. I’m of the opinion that you shouldn’t over-design analyzers unless you need to, so basically start in the analyzer itself until you reach that point where the analyzer is dealing with too many responsabilities and code is becoming harder to read, then design around it.


namespace DevOpsFlex.Analyzers
{
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
/// <summary>
/// Represents the Analyzer that enforces package consolidation (unique reference per package) and a unique packages folder
/// for each assembly being compiled.
/// </summary>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class PackageConsolidationAnalyzer : DiagnosticAnalyzer
{
/// <summary>
/// This exists as a private static for performance reasons. We might get into the space where the HashSet might become too big,
/// but we'll re-strategize if we get there.
/// </summary>
private static readonly HashSet<Package> Packages = new HashSet<Package>();
private static readonly DiagnosticDescriptor SinglePackagesFolderRule =
new DiagnosticDescriptor(
id: "DOF0001",
title: new LocalizableResourceString(nameof(Resources.SinglePackagesFolderTitle), Resources.ResourceManager, typeof(Resources)),
messageFormat: new LocalizableResourceString(nameof(Resources.SinglePackagesFolderMessageFormat), Resources.ResourceManager, typeof(Resources)),
category: "NuGet",
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true,
description: new LocalizableResourceString(nameof(Resources.SinglePackagesFolderDescription), Resources.ResourceManager, typeof(Resources)));
private static readonly DiagnosticDescriptor UniqueVersionRule =
new DiagnosticDescriptor(
id: "DOF0002",
title: new LocalizableResourceString(nameof(Resources.UniqueVersionTitle), Resources.ResourceManager, typeof(Resources)),
messageFormat: new LocalizableResourceString(nameof(Resources.UniqueVersionMessageFormat), Resources.ResourceManager, typeof(Resources)),
category: "NuGet",
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true,
description: new LocalizableResourceString(nameof(Resources.UniqueVersionDescription), Resources.ResourceManager, typeof(Resources)));
/// <summary>
/// Returns a set of descriptors for the diagnostics that this analyzer is capable of producing.
/// </summary>
public sealed override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(SinglePackagesFolderRule, UniqueVersionRule);
/// <summary>
/// Called once at session start to register actions in the analysis context.
/// </summary>
/// <param name="context">The <see cref="AnalysisContext"/> context used to register actions.</param>
public sealed override void Initialize(AnalysisContext context)
{
context.RegisterCompilationAction(AnalyzePackageConsolidation);
}
/// <summary>
/// Analyzes that package consolidation (unique reference per package) and a unique packages folder
/// are in place for each assembly being compiled. Because this is being run per assembly you might
/// see a repetition of the same error.
/// </summary>
/// <param name="context">The <see cref="CompilationAnalysisContext"/> context that parents all analysis elements.</param>
private static void AnalyzePackageConsolidation(CompilationAnalysisContext context)
{
var packageReferences = context.Compilation
.References
.Where(r => r is PortableExecutableReference)
.Cast<PortableExecutableReference>()
.Where(r => r.FilePath.ToLower().Contains(Package.PackagesFolderName))
.ToList();
if (!packageReferences.Any()) return;
var firstReferencePath = packageReferences.First().FilePath;
var packagesFolder = firstReferencePath.Substring(0, firstReferencePath.IndexOf(Package.PackagesFolderName, StringComparison.Ordinal) + Package.PackagesFolderName.Length);
// 1. Make sure there's only a packages folder
if (packageReferences.Any(r => !r.FilePath.Contains(packagesFolder)))
{
context.ReportDiagnostic(
Diagnostic.Create(
SinglePackagesFolderRule,
context.Compilation.Assembly.Locations[0],
context.Compilation.AssemblyName // {0} MessageFormat
));
}
// 2. Make sure for each reference in the packages folder, we're only dealing with a unique version
var newPackages = Directory.EnumerateDirectories(packagesFolder).Select(d => new Package(d)).Except(Packages);
foreach (var package in newPackages)
{
Packages.Add(package);
}
var packagesNotConsolidated = packageReferences.Select(r => new Package(r.FilePath))
.Where(r => Packages.Count(p => p.Name == r.Name) > 1);
foreach (var referencePackage in packagesNotConsolidated)
{
context.ReportDiagnostic(
Diagnostic.Create(
UniqueVersionRule,
context.Compilation.Assembly.Locations[0],
context.Compilation.AssemblyName, // {0} MessageFormat
referencePackage.Name // {1} MessageFormat
));
}
}
}
}

And the companion Package class


namespace DevOpsFlex.Analyzers
{
using System.Diagnostics.Contracts;
using System.IO;
using System.Text.RegularExpressions;
/// <summary>
/// Wraps logic around Name, Version and generic regular expression lazy initializations to support
/// the package consolidation analyzer.
/// </summary>
public class Package
{
private static readonly string PackageVersionRegex = PackagesFolderName.Replace("\\", "\\\\") + "[^0-9]*([0-9]+(?:\\.[0-9]+)+)(?:\\\\)?";
private static readonly string PackageNameRegex = PackagesFolderName.Replace("\\", "\\\\") + "([a-zA-Z]+(?:\\.[a-zA-Z]+)*)[^\\\\]*(?:\\\\)?";
private static readonly string PackageFolderRegex = "(.*" + PackagesFolderName.Replace("\\", "\\\\") + "[^\\\\]*)\\\\?";
private string _version;
private string _name;
/// <summary>
/// This is a convention constant that olds a string that all folders that we consider a "packages" folder contain.
/// </summary>
internal const string PackagesFolderName = "\\packages\\"; // convention
/// <summary>
/// Initializes a new instance of <see cref="Package"/>.
/// Has built in Contract validations that will all throw before any other code is able to throw.
/// </summary>
/// <param name="path">The path to the package folder that this package is based on.</param>
public Package(string path)
{
Contract.Requires(!string.IsNullOrEmpty(path));
Contract.Requires(Directory.Exists(path));
Contract.Requires(path.Contains(PackagesFolderName));
Contract.Requires(Regex.IsMatch(path, PackageFolderRegex, RegexOptions.Singleline), $"When casting string (path) to Package you need to ensure your path is being matched by the Folder Regex [{PackageFolderRegex}]");
Folder = Regex.Match(path, PackageFolderRegex, RegexOptions.Singleline).Groups[1].Value;
}
/// <summary>
/// Gets the package folder without the last "\".
/// </summary>
public string Folder { get; }
/// <summary>
/// Gets the package name component of the package folder as a string.
/// </summary>
public string Name => _name ?? (_name = Regex.Match(Folder, PackageNameRegex, RegexOptions.Singleline).Groups[1].Value);
/// <summary>
/// Gets the package version component of the package folder as a string.
/// </summary>
public string Version => _version ?? (_version = Regex.Match(Folder, PackageVersionRegex, RegexOptions.Singleline).Groups[1].Value);
/// <summary>
/// Determines whether the specified objects are equal.
/// </summary>
/// <param name="y">The second <see cref="Package"/> object to compare.</param>
/// <returns>true if the specified objects are equal; otherwise, false.</returns>
public override bool Equals(object y)
{
Contract.Requires(y != null);
Contract.Requires(y.GetType() == typeof(Package));
return Folder == (y as Package)?.Folder;
}
/// <summary>
/// Returns a hash code for the specified object.
/// </summary>
/// <returns>A hash code for the specified object.</returns>
public override int GetHashCode()
{
return Folder.GetHashCode();
}
}
}

view raw

Package.cs

hosted with ❤ by GitHub

Leave a comment