LegacyLibAnalyzer 0.2.6
dotnet add package LegacyLibAnalyzer --version 0.2.6
NuGet\Install-Package LegacyLibAnalyzer -Version 0.2.6
<PackageReference Include="LegacyLibAnalyzer" Version="0.2.6"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference>
<PackageVersion Include="LegacyLibAnalyzer" Version="0.2.6" />
<PackageReference Include="LegacyLibAnalyzer"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference>
paket add LegacyLibAnalyzer --version 0.2.6
#r "nuget: LegacyLibAnalyzer, 0.2.6"
#:package LegacyLibAnalyzer@0.2.6
#addin nuget:?package=LegacyLibAnalyzer&version=0.2.6
#tool nuget:?package=LegacyLibAnalyzer&version=0.2.6
LegacyLibAnalyzer
LegacyLibAnalyzer keeps an eye on your NuGet references so you don't have to — it flags legacy or forbidden packages the moment they're referenced, right during the build, and reports them to MSBuild (errors/warnings/notes).
It ships without built-in rules: you declare your rules directly via <LegacyLibraryRule> items in
your project, or centrally via your own company package (see below). The analyzer can also
write a SARIF report, e.g. for importing into SonarQube.
🕰️ Why this analyzer? Why not just fix the issue? If you maintain multiple repositories, you can't always drop a forbidden package right away — priorities and dependencies get in the way. And sometimes there isn't a replacement (yet).
This analyzer won't fix the problem for you, but it will make sure nobody forgets about it either.
📦 Installation
<ItemGroup>
<PackageReference Include="LegacyLibAnalyzer" Version="0.*" PrivateAssets="all" />
</ItemGroup>
PrivateAssets="all" is recommended for normal application/library projects. If you're
building your own company package (see below), do not set PrivateAssets="all" there,
since that would prevent the build integration from reaching consumers of your company
package.
⚙️ Defining rules
Rules are expressed as <LegacyLibraryRule> items. Add them to your own .csproj (after the
PackageReference to LegacyLibAnalyzer):
<ItemGroup>
<LegacyLibraryRule Include="Moq"
ErrorCode="LegacyLib-Moq"
Severity="Error"
Message="Moq is forbidden here; use NSubstitute." />
<LegacyLibraryRule Include="xUnitV1"
ErrorCode="LegacyLib-xUnitV1"
IncludePattern="xunit*"
ExcludePattern="xunit.v3*"
MaxVersion="1"
Severity="Error"
Message="xUnit v1 is legacy; upgrade to xunit.v3." />
<LegacyLibraryRule Include="xUnitV2"
ErrorCode="LegacyLib-xUnitV2"
IncludePattern="xunit*"
ExcludePattern="xunit.v3*"
MinVersion="2"
MaxVersion="2"
Severity="Warning"
Message="xUnit v2 is legacy; upgrade to xunit.v3." />
<LegacyLibraryRule Include="XunitV3Outdated"
ErrorCode="LegacyLib-XunitV3Outdated"
IncludePattern="xunit.v3*"
MaxVersion="3"
Severity="Note"
Message="xunit.v3 package is on major version 3.x; consider upgrading to 4.x." />
</ItemGroup>
🚫 This analyzer doesn't ship an opinion on which packages to forbid - it only enforces whatever policy you configure. One common trigger is a licensing change (e.g. FluentAssertions requiring a commercial license for commercial use as of v8). 💡 Always put the reason in the
Messageso developers understand the "why", not just the "what".
Rule options
| Metadata | Required | Description |
|---|---|---|
Include |
yes | Key of the rule and in general, the package name. Used to merge defaults with overrides (matched by this key). Wildcards are not supported here, if wildcards are needed, also specify IncludePattern |
IncludePattern |
no | The value actually matched against resolved package names. Supports * and ? wildcards (e.g. xunit*). Falls back to Include when not specified. |
ExcludePattern |
no | A wildcard pattern (*/?) of package names to still exclude, even if they match IncludePattern. Useful when a family of packages was renamed (e.g. xUnit v3 uses different package names than v2). |
ErrorCode |
no | The error/warning code (e.g. LegacyLib-Moq). Free-form text, no required format. Defaults to LegacyLib-Forbidden. Used as the SARIF rule id too. |
Severity |
no | Error, Warning or Note. Defaults to Warning. |
Category |
no | Maintainability, Reliability or Security. Defaults to Maintainability. Reported as a SARIF rule tag so SonarQube can group the issue under the matching category. |
Message |
no | The message shown to the user. Defaults to a generic message mentioning the package name. Use this to explain why the package is forbidden, not just that it is. |
MinVersion / MaxVersion |
no | Inclusive allowed version range. When the resolved package version falls within [MinVersion, MaxVersion] it is not flagged. 2, 2.0 are matched as 2.0.0.0. If version 2 is not allowed, set both MinVersion and MaxVersion to 2. Pre-release versions are matched by their release version, so 2.2.0-rc1 is treated as 2.2.0. |
🏢 Building your own company package
Got more than one repo and a policy everyone should follow? Instead of copy-pasting the same
<LegacyLibraryRule> items into every .csproj, bundle them once into a thin company package —
for example MyCompany.LegacyLibAnalyzer — that depends on LegacyLibAnalyzer and adds only
your own rules. Projects then just reference your company package and get the whole
organization's policy for free.
This works because the build integration files live under buildTransitive\ (not build\),
which NuGet happily propagates through the entire dependency chain, no matter how many packages
deep.
Steps:
- Create a new class library project (no source code needed) and reference this package
without
PrivateAssets="all", so the build integration flows through to consumers:<ItemGroup> <PackageReference Include="LegacyLibAnalyzer" Version="0.1.0" /> </ItemGroup> - Add your own
buildTransitive\<packageId>.targetsfile with your organization's<LegacyLibraryRule>items:<Project> <ItemGroup> <LegacyLibraryRule Include="Moq" ErrorCode="LegacyLib-Moq" Severity="Error" Message="Moq is forbidden; use NSubstitute instead." /> <LegacyLibraryRule Include="MyCompany.OldInternalLib" ErrorCode="MyCompany-OldInternalLib" Severity="Error" Message="Use MyCompany.NewInternalLib instead." /> </ItemGroup> </Project> - Pack and publish your company package. Consumers reference only your company package.
⚠️ Watch out for
NU5129— don't just suppress it. NuGet only imports a.props/.targetsfile frombuild\,buildTransitive\,buildCrossTargeting\orbuildMultiTargeting\if its filename matches the package id exactly (e.g.buildTransitive\MyCompany.LegacyLibAnalyzer.targetsfor package idMyCompany.LegacyLibAnalyzer, as used above). Get the name wrong and NuGet silently skips the file — no error at pack time, no error at restore time. The analyzer then simply never runs, and forbidden packages sneak through undetected while everything still "looks" green. So even though NuGet reports it as a warning, treatNU5129as a build-breaking error and fix it right away — never add it toNoWarn.In other words:
buildTransitive\<file>.targets(and.props) must be named exactly after your package's<PackageId>, with matching casing, e.g. a package id ofMyCompany.LegacyLibAnalyzerrequiresbuildTransitive\MyCompany.LegacyLibAnalyzer.targets. See examples/LegacyLibAnalyzerExample1/README.md for a working example, where the package idLegacyLibAnalyzerExample1is matched bybuildTransitive\LegacyLibAnalyzerExample1.targets.
Overruling company-wide rules in an individual project
Because <LegacyLibraryRule> items are merged by their Include key (last one wins, matched
case-insensitively), a consumer project can override — or locally soften/tighten — a rule
that came from your company package by declaring a <LegacyLibraryRule> with the same
Include value, after the PackageReference to the company package:
<ItemGroup>
<PackageReference Include="MyCompany.LegacyLibAnalyzer" Version="1.0.0" PrivateAssets="all" />
<LegacyLibraryRule Include="Moq" Severity="Warning"
Message="Moq is deprecated here; migration to NSubstitute in progress." />
</ItemGroup>
See examples/LegacyLibAnalyzerExample1 and examples/SampleLegacyProject in this repository
for a complete, working sample of this pattern. Note that LegacyLibAnalyzerExample1 now lives
in its own solution, examples/LegacyLibAnalyzerExample1.slnx, and consumes LegacyLibAnalyzer
as a NuGet package from the local feed, rather than via a project reference.
🔕 Disabling the check
Skips the analyzer entirely for the project: no rules are evaluated, and no errors, warnings, notes or SARIF output are produced.
<PropertyGroup>
<LegacyLibAnalyzerEnabled>false</LegacyLibAnalyzerEnabled>
</PropertyGroup>
📄 SARIF output
Enable SARIF output as follows:
<PropertyGroup>
<LegacyLibGenerateSarif>true</LegacyLibGenerateSarif>
<LegacyLibSarifOutputPath>$(MSBuildProjectDirectory)\legacylib.sarif</LegacyLibSarifOutputPath>
</PropertyGroup>
Add the path to your SonarQube analysis via the sonar.sarifReportPaths property, e.g. in the
sonarqube:configure/sonarqube:analyze Azure DevOps task's extra properties, or your
sonar-project.properties file:
sonar.sarifReportPaths=legacylib.sarif
The path is relative to the SonarQube analysis base directory (usually your repo root), so
adjust it to match where LegacyLibSarifOutputPath actually writes the file for your project.
Findings then show up as SonarQube issues, tagged with the Category you configured on the
rule (Maintainability, Reliability or Security).
⚠️ sonar.sarifReportPaths does not support wildcards. You must list every SARIF
file explicitly, comma-separated, e.g.
sonar.sarifReportPaths=legacylib.sarif,other/project/legacylib.sarif. This matters for
multi-project repositories: each project writes its own legacylib.sarif (see
LegacyLibSarifOutputPath above), so you'll need to add each path individually to your
SonarQube configuration — a glob like **/legacylib.sarif will not work.
Example SARIF output
A minimal SARIF v2.1.0 document produced for a single flagged package looks like this:
{
"version": "2.1.0",
"runs": [
{
"tool": { "driver": { "name": "LegacyLibAnalyzer" } },
"results": [
{
"ruleId": "LegacyLib-Moq",
"level": "error",
"message": { "text": "Moq is forbidden here; use NSubstitute. (package 'Moq' 4.20.0)" },
"locations": [
{
"physicalLocation": {
"artifactLocation": { "uri": "src/SampleProject/SampleProject.csproj" }
}
}
]
}
]
}
]
}
🗂️ Project structure
src/LegacyLibAnalyzer— the package project (MSBuild task +buildTransitive/integration).tests/LegacyLibAnalyzer.Tests— unit tests for the merging, matching and SARIF logic.examples/LegacyLibAnalyzerExample1— example company package with a curated set of rules, see examples/LegacyLibAnalyzerExample1/README.md for how it's built and consumed.examples/SampleLegacyProject— sample consumer project (an executable) using the example company package, see examples/SampleLegacyProject/README.md.examples/SampleLegacyClassLibrary— same asSampleLegacyProjectbut a class library instead of an executable, see examples/SampleLegacyClassLibrary/README.md.
All example projects live in their own solutions (
examples/LegacyLibAnalyzerExample1.slnx,examples/SampleLegacyProject.slnx,examples/SampleLegacyClassLibrary.slnx), separate from the mainLegacyLibAnalyzer.slnx.LegacyLibAnalyzerExample1consumesLegacyLibAnalyzeras a NuGet package (not a project reference), andSampleLegacyProject/SampleLegacyClassLibraryin turn depend on the NuGet package produced by packingLegacyLibAnalyzerExample1(see the linked READMEs for details on building them). CI builds and validates all of these as "systemtests" on both Windows and Linux, and for both the net8.0 and net10.0 buildTransitive task assemblies, explicitly checking for the symptoms from issue #11 (see.github/workflows/build-test-pack.ymlandscripts/Validate-LegacyLibWarnings.ps1).
Working with the examples locally
- The task ships three assemblies:
net472(Visual Studio /msbuild.exe), andnet8.0/net10.0(dotnet build), selected inLegacyLibAnalyzer.targetsbased on the MSBuild runtime/SDK. Verify both:dotnet buildandmsbuild examples/SampleLegacyClassLibrary.slnx -t:Restore,Build. - NuGet caches packages per version in
~/.nuget/packages. If you changeLegacyLibAnalyzerorLegacyLibAnalyzerExample1without bumping<Version>, consumers keep using the stale cached copy (so you may not see the expected warnings). Always bump the version, or clear the cached copy withdotnet nuget locals all --clear(or delete~/.nuget/packages/legacylibanalyzer*) and restore again.
🏗️ How it works
- On each build,
LegacyLibAnalyzer.CheckLegacyLibrariesTaskruns (hooked beforeBuild). - The task reads resolved
@(PackageReference)items and the merged@(LegacyLibraryRule)items (rules from any company package + consumer overrides). - It matches packages against the configured rules (wildcard patterns, optional exclude
pattern, optional
MinVersion/MaxVersionallowed range) and reports findings as an MSBuild error, warning or note, depending on the configured severity. - Optionally writes a SARIF report.
Learn more about Target Frameworks and .NET Standard.
This package has no dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
0.2.6:
- Harden the SARIF JSON writer: limit nesting depth, replace lone surrogates, and escape HTML-sensitive and Unicode line separator characters
0.2.5: package icon
0.2.4:
- Ship a net472 task assembly so Visual Studio / msbuild.exe can load the task (MSB4061)- #31
- Remove dependency on System.Text.Json to avoid runtime conflicts with Visual Studio / msbuild.exe
0.2.3:
- Fix MSB4061/MissingMethodException with TaskHostFactory by also packaging LegacyLibAnalyzer.runtimeconfig.json
alongside the task assembly and its deps.json (the out-of-process task host needs the runtimeconfig.json to resolve the correct runtime/dependencies)
0.2.2:
- Fix MSB4061/MissingMethodException with TaskHostFactory by packaging LegacyLibAnalyzer.deps.json alongside the task assembly
0.2.1:
- Fix out-of-process execution warning v2
0.2:
- Added `Category` option: `Maintainability` (default), `Reliability` or `Security`
- Renamed `Code` to `ErrorCode`, default to `LegacyLib-Forbidden`
- Fix out-of-process execution warning
- .NET 8 support